Eleven acts on your apps: run_command consent chips (reopen of #708) (#745)
* 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> * bot+web: Eleven files into your apps — the run_command consent chip 'Remind me to pick up the kids at 4pm' → the brain's app_command tool attaches a chip carrying the exact slash command; the tap runs it ON THE MEMBER'S DEVICE via the existing runCommand rail, filing into their personal Reminders/To-do thread — and parsing 'at 4pm' in their local timezone, which a sidecar never could. Consent shape: the tap is the permission, the label is ALWAYS the command itself (ui.js noLabel — a chip can't claim one thing and run another), only APP commands run (never bot/floor commands), @here is refused, and the safety preamble no longer forbids reminders — it points at the tool instead. promisedButton also catches 'I'll remind you…' so a promise without the chip self-heals into one. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix conflict resolution (brain.go, docs, tests) ---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
7 files changed,
+140
−16
changelog/2026-07-11-eleven-adds-reminders.md+6 −0docs/integrations.md+0 −9internal/bot/brain.go+51 −4internal/bot/brain_test.go+27 −2test/ui.test.mjs+15 −0web/static/shell.js+17 −0web/static/ui.js+24 −1
diff --git a/changelog/2026-07-11-eleven-adds-reminders.md b/changelog/2026-07-11-eleven-adds-reminders.md| new file mode 100644 |
| index 0000000..183016c |
| --- /dev/null |
| +++ b/changelog/2026-07-11-eleven-adds-reminders.md |
| @@ -0,0 +1,6 @@ |
| +🤖 Tell Eleven "remind me to pick up the kids at 4pm" and it can now do |
| +something about it: it attaches a button carrying the exact command — like |
| +`/remind pick up the kids at 4pm` — and one tap files it into your own |
| +Reminders (or `/todo` into your list). The tap is the permission: nothing is |
| +ever added without it, the button always shows exactly what it will do, and |
| +because it runs on your device, "4pm" means 4pm where you are. |
diff --git a/docs/integrations.md b/docs/integrations.md| index 68a41b5..e1b6500 100644 |
| --- a/docs/integrations.md |
| +++ b/docs/integrations.md |
| @@ -198,7 +198,6 @@ 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 |
| @@ -207,14 +206,6 @@ 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/internal/bot/brain.go b/internal/bot/brain.go| index a32006d..8767bd6 100644 |
| --- a/internal/bot/brain.go |
| +++ b/internal/bot/brain.go |
| @@ -49,8 +49,9 @@ func safety(name string) string { |
| "end-to-end encrypted group chat app; the server and its operator can't read messages, "+ |
| "and that privacy is the whole point of the app. You are one participant among the people here. "+ |
| "You may be in a one-to-one DM or in a group; in a group you're brought in only when someone addresses you directly (usually an @mention). "+ |
| - "You can only take part in the conversation — no reminders, notifications, files, or web access — "+ |
| - "so never promise to do those things; if asked, say you can't yet. "+ |
| + "You take part in the conversation — no files or web access of your own, and the ONLY ways you act "+ |
| + "beyond words are the tools you're given (one-tap buttons the member must tap; nothing runs from "+ |
| + "your say-so). Never promise anything a tool doesn't cover; if asked, say you can't yet. "+ |
| "This chat may include children: always be kind and age-appropriate, and never say anything scary, adult, or unsafe. "+ |
| "Each message is prefixed with the sender's name — reply naturally and don't prefix your own name.", name, app) |
| } |
| @@ -151,6 +152,13 @@ func (r *Runtime) systemPrompt() string { |
| "service_research chip instead: one tap consents to a short read-only look across the services " + |
| "you name, you gather what you need, and you answer in this thread. Reads only — anything that " + |
| "changes or buys stays a service_op confirmation chip." |
| + s += "\n\nYou can help members act in their own apps — Reminders, To-do — with the app_command " + |
| + "tool: it attaches a one-tap button carrying an exact slash command, which runs ON THEIR device " + |
| + "when they tap (that's also why it works: \"at 4pm\" is read in THEIR timezone, where their " + |
| + "reminders live). \"Remind me to pick up the kids at 4pm\" → app_command \"/remind pick up the " + |
| + "kids at 4pm\"; \"add milk to my list\" → app_command \"/todo buy milk\". Pass their words " + |
| + "through — don't convert times yourself. The tap is the permission ask: say the button's there, " + |
| + "never that you've added anything (you can't; the app confirms when they tap)." |
| if r.appGuide != "" { |
| s += "\n\nYou CAN build small personal mini-apps for members — private tools like trackers, " + |
| "lists, counters, tiny games — via the create_app tool. When someone asks for an app (or " + |
| @@ -208,7 +216,30 @@ func (r *Runtime) replyTools() []anthTool { |
| // trigger a build or mint a bot, but forcing a chip is safe — nothing runs |
| // without the member's tap. |
| func retryTools() []anthTool { |
| - return append(chipTools(), serviceOpTool(), serviceResearchTool()) |
| + return append(chipTools(), serviceOpTool(), serviceResearchTool(), appCommandTool()) |
| +} |
| + |
| +// appCommandTool proposes running one slash command on the member's own |
| +// device — the doorway into their personal apps (Reminders, To-do). The tap |
| +// is the consent; the client re-validates the command resolves to an app and |
| +// renders the command string itself as the label, so the chip can't claim |
| +// one thing and run another. |
| +func appCommandTool() anthTool { |
| + return anthTool{ |
| + Name: "app_command", |
| + Description: "Attach a one-tap button that runs a slash command on the member's device when THEY tap it — " + |
| + "how you file into their personal apps. Commands you may propose:\n" + |
| + "/remind <what> <when> — set a reminder; time phrases like \"at 4pm\", \"in 2 hours\", \"tomorrow 9:00\" " + |
| + "are parsed on their device in their local timezone, so pass the member's words through unchanged\n" + |
| + "/todo <task> — add a to-do to their list\n" + |
| + "One command per button, exactly as it should run. Never claim the thing is done — the tap does it " + |
| + "and the app confirms.", |
| + InputSchema: map[string]any{ |
| + "type": "object", |
| + "properties": map[string]any{"command": map[string]any{"type": "string", "description": "the exact slash command, e.g. /remind pick up the kids at 4pm"}}, |
| + "required": []any{"command"}, |
| + }, |
| + } |
| } |
| // serviceResearchTool proposes a READ-ONLY research session across one or |
| @@ -334,7 +365,7 @@ func (r *Runtime) buildContext(tc *ThreadCtx) ([]anthMessage, error) { |
| // that shipped) — the model cannot fetch anything itself, so a promise with |
| // no tool call is always wrong. Matching generously is cheap insurance: the |
| // forced retry only ATTACHES a consent chip; nothing runs without the tap. |
| -var promisedButton = regexp.MustCompile(`(?i)(\b(tap|button|chip)\b|\b(let me|i'?ll|i will|one moment while i|one sec while i)\s+(just\s+)?(pull|fetch|grab|check|look|get|list|query)\b)`) |
| +var promisedButton = regexp.MustCompile(`(?i)(\b(tap|button|chip)\b|\b(let me|i'?ll|i will|one moment while i|one sec while i)\s+(just\s+)?(pull|fetch|grab|check|look|get|list|query|remind|set a reminder|add)\b)`) |
| // modelReply is the conversational path: build context, ask Claude (adaptive |
| // thinking, chip tools), self-heal the promised-button case, return the reply. |
| @@ -641,11 +672,27 @@ func actionsFromToolUse(content []anthBlock) []map[string]any { |
| } |
| label := "Let me look at " + strings.Join(names, " & ") + " (read-only)" |
| out = append(out, map[string]any{"type": "service_research", "services": services, "goal": goal, "label": label}) |
| + case "app_command": |
| + // Mirror the client's sanitize (ui.js run_command): a slash command, |
| + // one line, bounded, never @here — fail at proposal, not at tap. |
| + cmd, _ := b.Input["command"].(string) |
| + cmd = strings.TrimSpace(cmd) |
| + if !strings.HasPrefix(cmd, "/") || len(cmd) < 2 || len(cmd) > 200 { |
| + continue |
| + } |
| + if strings.ContainsAny(cmd, "\n\r\t") || appCommandHereRE.MatchString(cmd) { |
| + continue |
| + } |
| + out = append(out, map[string]any{"type": "run_command", "command": cmd}) |
| } |
| } |
| return out |
| } |
| +// appCommandHereRE spots an @here token inside a proposed command — a group |
| +// install/file must never ride a tapped chip (same rule as the client's). |
| +var appCommandHereRE = regexp.MustCompile(`(?i)(^|\s)@here(\s|$)`) |
| + |
| func contains(list []string, v string) bool { |
| for _, x := range list { |
| if x == v { |
diff --git a/internal/bot/brain_test.go b/internal/bot/brain_test.go| index 1493e5d..383a703 100644 |
| --- a/internal/bot/brain_test.go |
| +++ b/internal/bot/brain_test.go |
| @@ -3,6 +3,7 @@ package bot |
| import ( |
| "os" |
| "regexp" |
| + "strings" |
| "testing" |
| ) |
| @@ -74,6 +75,8 @@ func TestPromisedButtonCatchesFetchPromises(t *testing.T) { |
| "Sure — let me check the domains on your account.", |
| "One moment while I query the repository.", |
| "I will list them for you.", |
| + "I'll remind you at 4pm.", |
| + "Let me add that to your list.", |
| } |
| for _, s := range matches { |
| if !promisedButton.MatchString(s) { |
| @@ -101,8 +104,10 @@ func TestRetryToolsIncludeServiceOp(t *testing.T) { |
| for _, tool := range retryTools() { |
| names[tool.Name] = true |
| } |
| - if !names["service_op"] { |
| - t.Fatal("forced retry cannot propose service_op — the promised op stays unfulfillable") |
| + for _, want := range []string{"service_op", "service_research", "app_command"} { |
| + if !names[want] { |
| + t.Fatalf("forced retry cannot propose %s — the promised action stays unfulfillable", want) |
| + } |
| } |
| for _, banned := range []string{"create_app", "create_bot", "remove_bot"} { |
| if names[banned] { |
| @@ -110,3 +115,23 @@ func TestRetryToolsIncludeServiceOp(t *testing.T) { |
| } |
| } |
| } |
| + |
| +// The app_command mapping: an exact slash command becomes a run_command chip; |
| +// anything that isn't one clean app command never becomes a chip at all. |
| +func TestActionsFromToolUseAppCommand(t *testing.T) { |
| + got := actionsFromToolUse([]anthBlock{ |
| + {Type: "tool_use", Name: "app_command", Input: map[string]any{"command": " /remind pick up the kids at 4pm "}}, |
| + }) |
| + if len(got) != 1 || got[0]["type"] != "run_command" || got[0]["command"] != "/remind pick up the kids at 4pm" { |
| + t.Fatalf("chip: %+v", got) |
| + } |
| + bad := actionsFromToolUse([]anthBlock{ |
| + {Type: "tool_use", Name: "app_command", Input: map[string]any{"command": "remind me at 4"}}, // no slash |
| + {Type: "tool_use", Name: "app_command", Input: map[string]any{"command": "/todo @here buy milk"}}, // group side effect |
| + {Type: "tool_use", Name: "app_command", Input: map[string]any{"command": "/todo a\nb"}}, // multi-line |
| + {Type: "tool_use", Name: "app_command", Input: map[string]any{"command": "/" + strings.Repeat("x", 300)}}, // too long |
| + }) |
| + if len(bad) != 0 { |
| + t.Fatalf("bad proposals became chips: %+v", bad) |
| + } |
| +} |
diff --git a/test/ui.test.mjs b/test/ui.test.mjs| index 7592d5d..7517106 100644 |
| --- a/test/ui.test.mjs |
| +++ b/test/ui.test.mjs |
| @@ -399,6 +399,21 @@ await test("sanitizeActions: service_research validates services and goal", () = |
| assert.equal(bad.length, 0); |
| }); |
| +await test("sanitizeActions: run_command takes one clean app command, label is the command", () => { |
| + const ok = sanitizeActions([{ type: "run_command", command: " /remind pick up the kids at 4pm ", label: "Open theme picker" }]); |
| + assert.equal(ok.length, 1); |
| + assert.equal(ok[0].command, "/remind pick up the kids at 4pm"); // trimmed |
| + assert.equal(ok[0].label, undefined); // a free label could lie about the tap — always render the command |
| + const bad = sanitizeActions([ |
| + { type: "run_command", command: "remind me at 4" }, // no slash |
| + { type: "run_command", command: "/" }, // empty verb |
| + { type: "run_command", command: "/todo @here buy milk" }, // group side effect from a tap |
| + { type: "run_command", command: "/todo a\nb" }, // multi-line |
| + { type: "run_command", command: "/" + "x".repeat(300) }, // too long |
| + { type: "run_command", command: "/todo a\x07b" }, // control chars |
| + ]); |
| + assert.equal(bad.length, 0); |
| +}); |
| await test("capAllows: a connection's cap is the grant ceiling", () => { |
| // Reads pass under every cap (and under no cap at all). |
| assert.equal(capAllows("read", "read"), true); |
diff --git a/web/static/shell.js b/web/static/shell.js| index 2684d6e..e2e313e 100644 |
| --- a/web/static/shell.js |
| +++ b/web/static/shell.js |
| @@ -72,6 +72,7 @@ import { |
| setSetupBridge, |
| setupBridge, |
| runCommand, |
| + parseCommand, |
| commandCatalog, |
| buildLocalNotice, |
| isAppType, |
| @@ -260,6 +261,19 @@ const CHIP_RUNNERS = { |
| ); |
| return a.access === "write" ? "Confirmed ✓" : "Requested ✓"; |
| }, |
| + // Run a slash command on THIS device — the tap is the permission ("add that |
| + // reminder"), and the command runs exactly where the member's apps live, so |
| + // /remind's time phrases parse in THEIR timezone. APP commands only: a bot |
| + // command or unknown verb is refused at the tap (parseCommand must resolve |
| + // to an owning app), and the app's own private reply ("I'll remind you…") |
| + // is the confirmation, rendered where the chip was tapped. |
| + run_command: (a) => { |
| + const threadId = activeId; |
| + const cmd = parseCommand(a.command, threadId); |
| + if (!cmd || !cmd.app) return "Not available here"; |
| + runCommand(a.command, threadId, (r) => appendNotice(threadId, r)); |
| + return "Done ✓"; |
| + }, |
| // Research session: the tap consents to a short READ-ONLY look across the |
| // named connected services; the bot's answer arrives as a normal reply in |
| // this thread, so success needs no notice — only a failure to start does. |
| @@ -298,6 +312,9 @@ const chipLabel = (a) => |
| open_thread: "Open the chat", |
| service_op: `Run ${a.op} on ${a.service}`, |
| service_research: `Look at ${(a.services || []).join(" & ")} (read-only)`, |
| + // The command IS the label (ui.js strips any free label) — a run_command |
| + // chip can never claim one thing and run another. |
| + run_command: a.command, |
| }[a.type]; |
| function buildActionChips(actions) { |
diff --git a/web/static/ui.js b/web/static/ui.js| index 0df609b..616ceb2 100644 |
| --- a/web/static/ui.js |
| +++ b/web/static/ui.js |
| @@ -317,6 +317,27 @@ export const CHIP_TYPES = { |
| return true; |
| }, |
| }, |
| + // Run a slash command on THIS device — how a bot files into your personal |
| + // apps ("remind me at 4pm" → a chip running `/remind pick up the kids at |
| + // 4pm`; the tap is the permission, and times parse in YOUR timezone because |
| + // the command runs where you are). Held to the harmless-and-reversible bar |
| + // three ways: the runner only accepts APP commands (never bot/floor |
| + // commands), "@here" is refused (no group side effects from a tap), and the |
| + // label is ALWAYS the command itself (noLabel) — a chip can never claim to |
| + // do one thing and run another. |
| + run_command: { |
| + longLabel: true, |
| + noLabel: true, // the command IS the label; a free label could lie about it |
| + sanitize: (a, chip) => { |
| + if (typeof a.command !== "string") return false; |
| + const cmd = a.command.trim(); |
| + if (!cmd.startsWith("/") || cmd.length < 2 || cmd.length > 200) return false; |
| + if (/[\x00-\x1f\x7f]/.test(cmd)) return false; // one line, no control chars |
| + if (/(^|\s)@here(\s|$)/i.test(cmd)) return false; // never a group install/file from a tap |
| + chip.command = cmd; |
| + return true; |
| + }, |
| + }, |
| // A read-only research session across connected services (docs/ |
| // integrations.md § research sessions): the tap seals a short-lived READ |
| // grant per named service to the bot, which answers the goal in-thread. |
| @@ -365,7 +386,9 @@ export function sanitizeActions(actions) { |
| chip[spec.param] = v; |
| } |
| if (spec.sanitize && !spec.sanitize(a, chip)) continue; |
| - if (typeof a.label === "string" && a.label.trim()) chip.label = a.label.trim().slice(0, spec.longLabel ? 160 : 40); |
| + // noLabel types render from their own params (run_command shows the |
| + // command itself) — a free label there could lie about what the tap does. |
| + if (!spec.noLabel && typeof a.label === "string" && a.label.trim()) chip.label = a.label.trim().slice(0, spec.longLabel ? 160 : 40); |
| out.push(chip); |
| } |
| return out; |