eleven / messenger Public

HubSpot: OAuth-ready manifest + dashboard app (reopen of #706) (#744)

* web+broker: choose a connection's access at connect time (read-only default)

Connecting a service in Integrations now asks what the connection may do:
read only (default) or read & write. The cap seals INSIDE the credential
blob (server never learns it); requestOp refuses to mint a grant beyond it
(capAllows, ui.js — the one testable rule), rotation preserves it, and the
catalog shows + flips it in place. OAuth manifests may declare scopes /
scopes_write so the cap also picks the provider-side scope set on the
authorize URL; the broker hands both lists through /config.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* bot+web: research sessions — one tap lets Eleven read across connected services

'Which of my DNSimple domains point at GitHub Pages?' → the brain proposes a
service_research chip naming the services; the tap seals a ~10-min READ grant
per service to the bot; the sidecar answers in a bounded tool loop
(research.go) whose palette is exactly the granted manifests' read ops —
writes are not in the model's world here. Results ride fenced in tool_result
blocks; rotated token pairs hand back sealed to the member on <rid>.renew;
tokens never enter a prompt (pinned by TestResearchEndToEnd).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* integrations: Stripe goes OAuth-ready + a Stripe dashboard app

stripe.json: auth both (Connect OAuth authorize/token endpoints; scopes
read_only, scopes_write read_write — the connection cap picks which), a
viewer op so pasted restricted keys verify on save, and payouts/
subscriptions reads with pick projections; balance.get now keeps available
AND pending. New web/static/apps/stripe.js: a takeover dashboard (balance
tiles, charges, payouts, subscriptions) fed by requestOp like the GitHub
app. Broker /config test pins that manifest scope lists ride through.
Enabling OAuth later = set LCHAT_OAUTH_STRIPE_CLIENT_ID/_CLIENT_SECRET on
the broker; until then paste-a-key works today.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* integrations: HubSpot goes OAuth-ready + a HubSpot dashboard app

hubspot.json: auth both (app.hubspot.com authorize + api.hubapi.com token;
CRM read scopes, contacts.write only on the read & write cap), a viewer op
(account details) so pasted private-app tokens verify on save, and a
deals.list read with pick projection. HubSpot's 30-minute access tokens ride
the existing expiring-pair rotation unchanged. New web/static/apps/
hubspot.js: takeover dashboard (recent contacts + deals) fed by requestOp.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 0834ae0781a6e979b0ec74cfb1f56f1184555df0 parent 83fff12
5 files changed, +292 −2
  • changelog/2026-07-11-hubspot-app.md +6 −0
  • docs/integrations.md +11 −0
  • integrations/registry/manifests/hubspot.json +42 −2
  • web/static/apps/hubspot.js +232 −0
  • web/static/shell.js +1 −0
diff --git a/changelog/2026-07-11-hubspot-app.md b/changelog/2026-07-11-hubspot-app.md
new file mode 100644
index 0000000..07a9596
--- /dev/null
+++ b/changelog/2026-07-11-hubspot-app.md
@@ -0,0 +1,6 @@
+🤖 HubSpot joins Integrations too: connect your account — on HubSpot's own
+site or with a private-app token, read-only or read & write — and a
+**HubSpot app** appears in + New chat with your recent contacts and deals.
+Ask Eleven things like "any new contacts this week?" and it can take a
+read-only look. As always, your token is sealed in your browser and the
+server never sees it.
diff --git a/docs/integrations.md b/docs/integrations.md
index e4a3ef5..68a41b5 100644
--- a/docs/integrations.md
+++ b/docs/integrations.md
@@ -198,12 +198,23 @@ can do, growable by PR without touching this codebase. The pipeline:
Out of scope, called out honestly: standing charter grants (above),
registering the OAuth apps themselves (a human act per provider — the Stripe
+<<<<<<< HEAD
+and HubSpot manifests are OAuth-ready, scopes mapped to the connection cap
+[Stripe `read_only`/`read_write`; HubSpot CRM read scopes, contacts.write
+only on a read & write connection]; the broker offers each Connect button
+the moment `LCHAT_OAUTH_<ID>_CLIENT_ID`/`_CLIENT_SECRET` land in its env,
+and until then the catalog offers paste-a-token alone. NB Stripe's OAuth
+`client_secret` is the platform's own secret key — treat its env value with
+that weight. HubSpot's 30-minute access tokens ride the expiring-pair
+rotation above unchanged),
+=======
manifest is OAuth-ready with `read_only`/`read_write` scopes mapped to the
connection cap; the broker offers its Connect button the moment
`LCHAT_OAUTH_STRIPE_CLIENT_ID`/`_CLIENT_SECRET` land in its env, and until
then the catalog offers paste-a-restricted-key alone. NB Stripe's OAuth
`client_secret` is the platform's own secret key — treat its env value with
that weight),
+>>>>>>> origin/main
Disconnect-revokes-provider-side, native catalog UI (connected services
remain web-only), and the Node reference sidecar (`agent/`) neither executes
registry ops nor proposes `service_op` — the Go sidecar is the production
diff --git a/integrations/registry/manifests/hubspot.json b/integrations/registry/manifests/hubspot.json
index f6f8bfb..db102fc 100644
--- a/integrations/registry/manifests/hubspot.json
+++ b/integrations/registry/manifests/hubspot.json
@@ -4,12 +4,34 @@
"name": "HubSpot",
"category": "crm",
"auth": {
- "kind": "pat",
+ "kind": "both",
"base": "https://api.hubapi.com",
"authorization": "Bearer",
- "pat_help": "https://developers.hubspot.com/docs/api/private-apps — create a private app with only the CRM scopes you want reachable"
+ "pat_help": "https://developers.hubspot.com/docs/api/private-apps — create a private app with only the CRM scopes you want reachable",
+ "oauth": {
+ "authorize_url": "https://app.hubspot.com/oauth/authorize",
+ "token_url": "https://api.hubapi.com/oauth/v1/token",
+ "scopes": ["oauth", "crm.objects.contacts.read", "crm.objects.deals.read"],
+ "scopes_write": ["oauth", "crm.objects.contacts.read", "crm.objects.deals.read", "crm.objects.contacts.write"]
+ }
},
"ops": [
+ {
+ "id": "viewer",
+ "label": "Which account am I connected to",
+ "access": "read",
+ "method": "GET",
+ "path": "/account-info/v3/details",
+ "pick": {
+ "fields": [
+ "portalId",
+ "accountType",
+ "timeZone",
+ "companyCurrency",
+ "uiDomain"
+ ]
+ }
+ },
{
"id": "contacts.list",
"label": "List recent contacts",
@@ -27,6 +49,24 @@
]
}
},
+ {
+ "id": "deals.list",
+ "label": "List recent deals",
+ "access": "read",
+ "method": "GET",
+ "path": "/crm/v3/objects/deals?limit=10&properties=dealname,amount,dealstage,closedate",
+ "pick": {
+ "root": "results",
+ "fields": [
+ "id",
+ "properties.dealname",
+ "properties.amount",
+ "properties.dealstage",
+ "properties.closedate",
+ "createdAt"
+ ]
+ }
+ },
{
"id": "contact.create",
"label": "Create a contact",
diff --git a/web/static/apps/hubspot.js b/web/static/apps/hubspot.js
new file mode 100644
index 0000000..2ede387
--- /dev/null
+++ b/web/static/apps/hubspot.js
@@ -0,0 +1,232 @@
+// The HubSpot companion app (docs/integrations.md § companion apps). Once
+// you've connected HubSpot in Integrations (OAuth or a private-app token),
+// this app installs: recent contacts and deals as a small dashboard in its
+// own thread.
+//
+// Same rail as the GitHub and Stripe apps: NO HubSpot sidecar. Every fetch
+// goes through ctx.host.connected.requestOp (a one-shot read grant sealed to
+// the instance's existing bot); results are bounded by the hubspot manifest's
+// `pick` projections, fold into state as typed events, and sync across
+// devices via snapshots. TAKEOVER app (no chatMessage): CRM data never echoes
+// into chat bubbles or notifications.
+import { defineApp } from "../apps.js";
+
+// Results arrive pick-projected with flattened dot-path keys
+// ("properties.email") — normalise once at fold time, drop the malformed.
+const normContacts = (list) =>
+ (Array.isArray(list) ? list : [])
+ .filter((c) => c && typeof c.id === "string")
+ .map((c) => ({
+ id: c.id,
+ email: typeof c["properties.email"] === "string" ? c["properties.email"] : "",
+ first: typeof c["properties.firstname"] === "string" ? c["properties.firstname"] : "",
+ last: typeof c["properties.lastname"] === "string" ? c["properties.lastname"] : "",
+ created: typeof c.createdAt === "string" ? c.createdAt : "",
+ }));
+
+const normDeals = (list) =>
+ (Array.isArray(list) ? list : [])
+ .filter((d) => d && typeof d.id === "string")
+ .map((d) => ({
+ id: d.id,
+ name: typeof d["properties.dealname"] === "string" ? d["properties.dealname"] : "",
+ amount: typeof d["properties.amount"] === "string" ? d["properties.amount"] : "",
+ stage: typeof d["properties.dealstage"] === "string" ? d["properties.dealstage"] : "",
+ close: typeof d["properties.closedate"] === "string" ? d["properties.closedate"] : "",
+ created: typeof d.createdAt === "string" ? d.createdAt : "",
+ }));
+
+// ISO stamp → short local date; "" when unparsable.
+function day(iso) {
+ const t = Date.parse(iso || "");
+ if (!t) return "";
+ try {
+ return new Date(t).toLocaleDateString(undefined, { day: "numeric", month: "short" });
+ } catch {
+ return "";
+ }
+}
+
+// HubSpot deal stages are internal slugs ("appointmentscheduled") — make them
+// readable without pretending to know the pipeline's real labels.
+const stageLabel = (s) => (s || "").replace(/[_-]+/g, " ").trim() || "—";
+
+let busy = false;
+let autoLoading = false;
+
+export const app = defineApp({
+ name: "hubspot",
+ title: "HubSpot",
+ blurb: "Contacts & deals",
+ glyph: "🧲",
+ scope: "personal",
+ // Only listed in New chat once HubSpot is linked (shell.js gates on this).
+ requiresService: "hubspot",
+ initialState: () => ({ contacts: [], deals: [], fetchedAt: 0 }),
+
+ events: {
+ "hubspot.contacts": (s, e) => (Array.isArray(e.contacts) ? { ...s, contacts: normContacts(e.contacts), fetchedAt: e.ts || s.fetchedAt } : s),
+ "hubspot.deals": (s, e) => (Array.isArray(e.deals) ? { ...s, deals: normDeals(e.deals), fetchedAt: e.ts || s.fetchedAt } : s),
+ },
+
+ styles: `
+.hs-head { display: flex; align-items: baseline; gap: 12px; margin: 0 0 14px; }
+.hs-title { margin: 0; font-size: 18px; }
+.hs-sub { margin-left: auto; font-size: 12px; color: var(--muted); white-space: nowrap; }
+.hs-btn { flex: 0 0 auto; padding: 6px 12px; border-radius: 9px; border: 1px solid var(--border); background: var(--panel); color: var(--ink); font: inherit; cursor: var(--ui-cursor, pointer); }
+.hs-btn:disabled { opacity: .6; cursor: default; }
+.hs-h { margin: 18px 0 6px; font-size: 13px; font-weight: 600; letter-spacing: .03em; text-transform: uppercase; color: var(--muted); }
+.hs-scroll { overflow-x: auto; margin: 0 -4px; }
+.hs-table { width: 100%; border-collapse: collapse; font-size: 14px; }
+.hs-table th { text-align: left; font-weight: 600; font-size: 11px; letter-spacing: .04em; text-transform: uppercase; color: var(--muted); padding: 6px 10px; border-bottom: 1px solid var(--border); white-space: nowrap; }
+.hs-table td { padding: 8px 10px; border-bottom: 1px solid var(--border); vertical-align: top; }
+.hs-name { font-weight: 500; overflow-wrap: anywhere; }
+.hs-muted { color: var(--muted); overflow-wrap: anywhere; }
+.hs-amount { text-align: right; white-space: nowrap; font-variant-numeric: tabular-nums; font-weight: 500; }
+.hs-when { color: var(--muted); white-space: nowrap; font-variant-numeric: tabular-nums; }
+.hs-empty { color: var(--muted); padding: 8px 0; }
+.hs-note { margin-top: 12px; min-height: 18px; font-size: 13px; color: var(--muted); }
+`,
+
+ render(state, el, ctx) {
+ const host = ctx.host && ctx.host.connected;
+ state = state || { contacts: [], deals: [], fetchedAt: 0 };
+
+ const head = document.createElement("div");
+ head.className = "hs-head";
+ el.appendChild(head);
+
+ const title = document.createElement("h2");
+ title.className = "hs-title";
+ title.textContent = "HubSpot";
+ const sub = document.createElement("span");
+ sub.className = "hs-sub";
+
+ const note = document.createElement("div");
+ note.className = "hs-note";
+
+ if (!host || !host.requestOp) {
+ head.appendChild(title);
+ const empty = document.createElement("div");
+ empty.className = "hs-empty";
+ empty.textContent = "HubSpot needs the web app for now — open this chat in a web browser (connecting isn't supported in the app yet).";
+ el.append(empty);
+ return;
+ }
+
+ // One Refresh pulls both reads; each folds as its own event so a failing
+ // one never blanks the other. Whole body under try/finally so a throw can
+ // never wedge the module-level busy flag.
+ const refresh = async () => {
+ if (busy) return;
+ busy = true;
+ const errors = [];
+ try {
+ note.textContent = "";
+ paint();
+ const pulls = [
+ ["contacts.list", (d) => ctx.emit({ type: "hubspot.contacts", contacts: d })],
+ ["deals.list", (d) => ctx.emit({ type: "hubspot.deals", deals: d })],
+ ];
+ await Promise.all(
+ pulls.map(async ([op, emit]) => {
+ let res;
+ try {
+ res = await host.requestOp("hubspot", op, {});
+ } catch (e) {
+ res = { ok: false, error: (e && e.message) || String(e) };
+ }
+ if (res && res.ok && typeof res.data === "string") {
+ try {
+ emit(JSON.parse(res.data));
+ return;
+ } catch {}
+ }
+ errors.push(`${op}: ${(res && res.error) || "no response"}`);
+ }),
+ );
+ } finally {
+ busy = false;
+ autoLoading = false;
+ }
+ if (errors.length) note.textContent = `Couldn't refresh everything — ${errors[0]}`;
+ paint();
+ };
+
+ const refreshBtn = document.createElement("button");
+ refreshBtn.type = "button";
+ refreshBtn.className = "hs-btn";
+ refreshBtn.textContent = "↻ Refresh";
+ refreshBtn.onclick = refresh;
+ head.append(title, sub, refreshBtn);
+
+ const section = (heading, rows, cols, rowCells) => {
+ const h = document.createElement("div");
+ h.className = "hs-h";
+ h.textContent = heading;
+ el.appendChild(h);
+ if (!rows.length) {
+ const empty = document.createElement("div");
+ empty.className = "hs-empty";
+ empty.textContent = state.fetchedAt ? "Nothing here." : "Not loaded yet — hit Refresh.";
+ el.appendChild(empty);
+ return;
+ }
+ const scroll = document.createElement("div");
+ scroll.className = "hs-scroll";
+ const table = document.createElement("table");
+ table.className = "hs-table";
+ const thead = document.createElement("thead");
+ const hr = document.createElement("tr");
+ for (const c of cols) {
+ const th = document.createElement("th");
+ th.textContent = c;
+ hr.appendChild(th);
+ }
+ thead.appendChild(hr);
+ table.appendChild(thead);
+ const tbody = document.createElement("tbody");
+ for (const r of rows) {
+ const tr = document.createElement("tr");
+ for (const cell of rowCells(r)) tr.appendChild(cell);
+ tbody.appendChild(tr);
+ }
+ table.appendChild(tbody);
+ scroll.appendChild(table);
+ el.appendChild(scroll);
+ };
+
+ const td = (text, cls) => {
+ const c = document.createElement("td");
+ if (cls) c.className = cls;
+ c.textContent = text;
+ return c;
+ };
+
+ section("Recent contacts", state.contacts, ["Name", "Email", "Added"], (c) => [
+ td([c.first, c.last].filter(Boolean).join(" ") || "—", "hs-name"),
+ td(c.email || "—", "hs-muted"),
+ td(day(c.created), "hs-when"),
+ ]);
+ section("Recent deals", state.deals, ["Deal", "Amount", "Stage", "Closes"], (d) => [
+ td(d.name || "—", "hs-name"),
+ td(d.amount ? d.amount : "—", "hs-amount"),
+ td(stageLabel(d.stage), "hs-muted"),
+ td(day(d.close), "hs-when"),
+ ]);
+
+ el.appendChild(note);
+
+ const paint = () => {
+ sub.textContent = busy ? "loading…" : state.fetchedAt ? `updated ${new Date(state.fetchedAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}` : "";
+ refreshBtn.disabled = busy;
+ };
+ paint();
+
+ // First open pulls once, automatically — after the surface is fully built.
+ if (!state.fetchedAt && !busy && !autoLoading) {
+ autoLoading = true;
+ refresh();
+ }
+ },
+});
diff --git a/web/static/shell.js b/web/static/shell.js
index 79a4e2e..2684d6e 100644
--- a/web/static/shell.js
+++ b/web/static/shell.js
@@ -114,6 +114,7 @@ import "/static/apps/board.js";
import "/static/apps/connect.js";
import "/static/apps/dnsimple.js";
import "/static/apps/stripe.js";
+import "/static/apps/hubspot.js";
import "/static/apps/github.js";
import "/static/apps/emoji.js"; // /emoji — the command-panel reference app
import "/static/apps/gif.js"; // /gif — Giphy search in a command panel (bring-your-own key; posts via ctx.host.gif)