eleven / messenger Public

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>
Paul Campbell fd95b96a2ba99c6b5c68862eedf17eb38f1a06da parent 0834ae0
7 files changed, +140 −16
  • changelog/2026-07-11-eleven-adds-reminders.md +6 −0
  • docs/integrations.md +0 −9
  • internal/bot/brain.go +51 −4
  • internal/bot/brain_test.go +27 −2
  • test/ui.test.mjs +15 −0
  • web/static/shell.js +17 −0
  • web/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;