Skip to content
11 changes: 11 additions & 0 deletions plugins/brains/generated/capability-catalog.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"schema_version": 1,
"catalog_schema_version": 1,
"renderer_version": 2,
"capability_id": "integration-actions",
"source_repository": "https://github.com/ssvlabs/brains",
"source_generator": "apps/mcp/scripts/generate-capability-catalog.ts",
"catalog_sha256": "c46b9cf2f88ed3cefe0f5766665c12f06dafaa0e56dba1f5586ab9cd56c39ac2",
"artifact_path": "plugins/brains/skills/brains-write/SKILL.md",
"artifact_sha256": "f14839e4ed852771fc8dd91b0196f9caeeb63ce56dead747a53d83fd83dd9475"
}
116 changes: 51 additions & 65 deletions plugins/brains/skills/brains-write/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,73 +3,59 @@ name: brains-write
description: How to take ACTIONS on the user's integrations through brains — send an email, create a calendar event, make a Drive doc, comment on a Monday item, run a live Gmail search, post a message. Use whenever the user wants to send/create/reply/forward/mark/schedule something, or "message/ping/DM" someone. Covers the codex action flow and the draft→confirm gate.
---

# Acting on integrations (writes)

Codex-first. Each connected integration declares its own actions; discover and
dispatch them generically. If nothing matches, refine the discovery query or say
no installed action covers it; there is no source-enum fallback.

## The codex path (preferred)

1. **Discover** — `query type=integration_action text="<natural-language intent>"`.
It returns ranked page slugs; call `get_page` on the selected result to read
its frontmatter: `install_id`, `action_name`, `description`, `input_schema`,
`requires_confirmation`, and `side_effect`. The page body carries example
requests — strong hints for shaping `input`.
2. **Dispatch** — `act_on_integration install_id=<…> action_name=<…> input={…}`
(input matches `input_schema`; do not send a free-form `request`).
Build `input` yourself from the user's words; if the ask is fuzzy, the
discovery `query` + the action's `examples` tell you which action and shape.

### Return kinds — know which is the success state

- **`requires_confirmation: false`** → executes inline now, returns
`{kind:"auto_executed", result, action_record_id}`. **Done** — do NOT try to
confirm it, and do not assume it was read-only.
- **`kind:"rate_limited"`** → nothing ran and no upstream call was made. Retry
after `retry_after_seconds`.
- **`kind:"auto_failed"`** → the action was attempted, so whether an outbound
write reached the provider is **unknown**. Never blind-retry: read the state
back (or send the user to `/inbox`) before re-sending. A pure read is safe to
retry.
- **`requires_confirmation` absent from the frontmatter** → the page predates
the field, so the outcome cannot be predicted. Never assume it will draft:
dispatch the full tuple, branch on the returned `kind`, and report
`auto_executed` in the past tense.
- **`requires_confirmation: true`** → returns
`{kind:"draft", draft_id, action, preview, payload, confirm_hint, expires_at}`.
Relay the `preview` and `confirm_hint`, then stop. The user confirms through
the real controls in `/inbox` (web/mobile) or Telegram. Do **not** call
`confirm_action` from this agent loop. If the user cancels or corrects the
draft, call `discard_action`; for a correction, draft the structured action
again with the new input. Destructive sends (email, calendar invite, doc
create) live here. After 1 hour a draft moves to the `/inbox` Expired tab but
remains approvable, so discard the old draft before redrafting.
<!-- Generated by ssvlabs/brains apps/mcp/scripts/generate-capability-catalog.ts.
Source capability: integration-actions. Do not hand-edit. -->

The out-of-band surfaces hold the confirmation capability; this chat does not.

### Live Gmail search

gmail-inbox ships `search_emails` (`requires_confirmation: false`) — runs native
Gmail search at runtime for mail the ingested pages don't cover. `input={query:
"<gmail syntax>", limit: 1..50}`; pass `write_pages: true` to ALSO persist each
match as an `email` page. Reach for it AFTER `list_pages`/`search` come up short,
not before.

## Gmail / Calendar / Drive
# Acting on integrations (writes)

These are codex installs like any other. `query type=integration_action` finds
their action pages; `get_page` reveals the `install_id`. Dispatch the same
`install_id` + `action_name` + `input` tuple. A bare `source` drafts nothing.
Each connected integration declares its own actions. If nothing matches, refine
the discovery query or say no installed action covers it; there is no
source-enum fallback.

Discover with `query type=integration_action text="<intent>"`.
It returns slug/title/snippet, so call `get_page` on the selected
slug to read frontmatter. Use its `install_id`, `action_name`, and structured
`input` in `act_on_integration`; this tuple is the only call
shape. Partial tuples error; only bare legacy `source` returns `clarification`.
`requires_confirmation:true` drafts for out-of-band approval;
`requires_confirmation:false` runs inline. If
`requires_confirmation` is absent, the page predates the field: treat whether
it drafts or runs as unknown. `side_effect` says where it writes
(`external` = the provider, visible outside brains;
`null` or absent = undeclared, treat as external). Inline external writes
include `rsvp_event`,
`create_draft`, and `add_labels`; do not infer safety from read vs write.
Cap: 30 auto-executions/install/60s.
Automation `dry_run` suppresses external writes to no-call `[DRY RUN]` drafts.

| `kind` | What happened | What you do |
|---|---|---|
| `draft` | Nothing sent; it carries `preview`, `confirm_hint`, and `expires_at`. | Relay the preview and hint, then stop; only `draft` carries `confirm_hint`. |
| `auto_executed` | It already ran; it carries `result` and `action_record_id`. | Report what happened, past tense; never say it is awaiting approval. |
| `auto_failed` | It was attempted; whether an outbound write reached the provider is **unknown**. | Don't blind-retry. Read provider state or `/inbox` before re-sending; retry only when the action is idempotent or the error proves nothing was sent. A pure read is safe to retry. |
| `rate_limited` | Nothing ran and no upstream call occurred. | Retry after `retry_after_seconds`. |
| `clarification` | A `source`-only legacy call reached the shim; no action ran. | Answer its `question`, then resend the full tuple. |
| `noop` | The integration is unavailable, or the action was suppressed because an automation is running in verify mode. | Relay its `reason`. If it starts with `verify_mode:`, report that the action was safely suppressed; otherwise point to `/integrations`. |

You can't confirm a draft from this loop. Only the user's out-of-band
`/inbox` or Telegram surface has the confirmation secret. So never call `confirm_action` yourself.
After one hour a draft moves to the `/inbox` Expired tab but remains
approvable. There is no edit-at-confirm step: call
`discard_action` before re-drafting corrected input.

The selected action page also carries `description`, `input_schema`,
`side_effect`, and example requests. Build structured `input` from those
facts; do not send a free-form `request`.

## Live Gmail search

`gmail-inbox` ships `search_emails` with `requires_confirmation:false`.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Names the gmail-inbox live-search action search_emails, but plugins/brains/skills/brains-read/SKILL.md:58 calls the same action query_emails and points the reader here for the details. One of the two makes the agent dispatch a non-existent action_name. Settle which is real and align both — this file is generated, so the fix belongs in the upstream catalog.

Use `input={query:"<gmail syntax>", limit:1..50}`; `write_pages:true` also
persists each match. Reach for it only after `list_pages` or `search` comes
up short.

## Routing a generic "message someone"

No hardcoded default channel. For a
generic "send a msg / message X / ping / DM" with no channel named, use the codex
discovery above (`query type=integration_action`) and dispatch to whichever
messaging integration the user actually has connected. Use `gmail` only when they
say "email," give an email address, or are replying to/forwarding a thread.
`calendar`/`drive` only when explicit.

**Always relay a destructive action's preview and confirmation hint, then leave
the decision to the user's out-of-band approval surface.**
Do not hardcode a default channel. Discover the messaging actions the user
actually has installed. Use Gmail only when they say "email", provide an email
address, or are replying to or forwarding a thread.
80 changes: 63 additions & 17 deletions tests/plugin-contract/run.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
#!/usr/bin/env bun

import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";

Expand Down Expand Up @@ -141,6 +142,7 @@ const inboxHook = readFileSync(join(PLUGIN, "hooks", "lib", "brains-inbox.sh"),
const readme = readFileSync(join(ROOT, "README.md"), "utf8");
const core = readFileSync(join(PLUGIN, "core.md"), "utf8");
const writeSkill = readFileSync(join(PLUGIN, "skills", "brains-write", "SKILL.md"), "utf8");
const capabilityManifest = readJson(join(PLUGIN, "generated", "capability-catalog.json"));
const coreNormalized = core.replace(/\s+/g, " ");
const writeSkillNormalized = writeSkill.replace(/\s+/g, " ");

Expand Down Expand Up @@ -438,41 +440,85 @@ for (const signal of [
}
assert(core.length < 3_000, "always-loaded core must stay below 3,000 characters");

// This public plugin is a sixth model-visible copy of the act contract, outside
// the monorepo's ACT_CONTRACT_COPIES gate. Mirror its four load-bearing rules
// here so a future compaction cannot drift independently again.
assert(writeSkillNormalized.includes("install_id=<…> action_name=<…> input={…}"), "structured action tuple missing");
assert(writeSkillNormalized.includes("call `get_page` on the selected result"), "action discovery must resolve frontmatter");
// The public face is generated from the monorepo capability catalog. Verify its
// immutable artifact digest locally; installation never fetches a mutable copy.
assert(capabilityManifest.schema_version === 1, "capability manifest schema mismatch");
assert(capabilityManifest.catalog_schema_version === 1, "catalog schema mismatch");
assert(capabilityManifest.renderer_version === 2, "catalog renderer mismatch");
assert(capabilityManifest.capability_id === "integration-actions", "capability id mismatch");
assert(
capabilityManifest.artifact_path === "plugins/brains/skills/brains-write/SKILL.md",
"generated artifact path mismatch",
);
assert(
capabilityManifest.artifact_sha256 ===
createHash("sha256").update(writeSkill, "utf8").digest("hex"),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The digest pin hashes writeSkill raw, while every other content pin in this file goes through normalizeRegion (line 123), which explicitly collapses \r\n. A checkout with core.autocrlf=true therefore fails generated brains-write artifact digest mismatch with no fixable cause — and .gitattributes is listed in .gitignore, so line endings can't be pinned per-repo either. Hash writeSkill.replace(/\r\n?/g, "\n") (or reuse normalizeRegion's EOL step) to match the generator's byte assumption.

"generated brains-write artifact digest mismatch",
);
assert(
!/automation_secret|adminPool|handler_source|telegram_push|grant_token/.test(writeSkill),
"public skill leaked an internal-only capability",
);

// Keep independent semantic assertions: digest equality proves provenance, not
// that the canonical source itself kept the load-bearing safety rules.
assert(
writeSkillNormalized.includes("`requires_confirmation` absent from the frontmatter") &&
writeSkillNormalized.includes("the outcome cannot be predicted") &&
writeSkillNormalized.includes("Never assume it will draft"),
writeSkillNormalized.includes("its `install_id`, `action_name`, and structured `input` in `act_on_integration`") &&
writeSkillNormalized.includes("this tuple is the only call shape"),
"structured action tuple missing",
);
assert(writeSkillNormalized.includes("call `get_page` on the selected"), "action discovery must resolve frontmatter");
assert(
writeSkillNormalized.includes("`requires_confirmation` is absent") &&
writeSkillNormalized.includes("treat whether it drafts or runs as unknown"),
"absent requires_confirmation must remain unknown rather than predict a draft",
);
assert(
writeSkillNormalized.includes('**`requires_confirmation: false`** → executes inline now, returns') &&
writeSkillNormalized.includes('{kind:"auto_executed", result, action_record_id}'),
"requires_confirmation:false must be documented as already executed",
writeSkillNormalized.includes("`requires_confirmation:true` drafts for out-of-band approval") &&
writeSkillNormalized.includes("`requires_confirmation:false` runs inline") &&
writeSkillNormalized.includes("| `auto_executed` | It already ran; it carries `result` and `action_record_id`."),
"both requires_confirmation branches must retain their distinct behavior",
);
assert(
writeSkillNormalized.includes("Partial tuples error") &&
writeSkillNormalized.includes("only bare legacy `source` returns `clarification`"),
"partial tuples must error; only the bare legacy source shim may clarify",
);
assert(writeSkillNormalized.includes("there is no source-enum fallback"), "legacy source-enum fallback must stay removed");
assert(!writeSkillNormalized.includes("Fall back to the legacy source-enum"), "stale legacy fallback pointer must not return");
assert(writeSkillNormalized.includes("action_record_id"), "auto-executed result must expose action_record_id");
assert(!writeSkillNormalized.includes("audit_id"), "stale auto-executed audit_id field must not return");
assert(
writeSkillNormalized.includes('**`kind:"rate_limited"`** → nothing ran and no upstream call was made'),
writeSkillNormalized.includes("| `rate_limited` | Nothing ran and no upstream call occurred."),
"rate-limited actions must be documented as not attempted",
);
assert(
writeSkillNormalized.includes("30 auto-executions/install/60s"),
"auto-execution rate-limit context missing",
);
assert(writeSkillNormalized.includes("| `clarification` |"), "clarification result kind missing");
assert(writeSkillNormalized.includes("| `noop` |"), "noop result kind missing");
assert(
writeSkillNormalized.includes("whether an outbound write reached the provider is **unknown**"),
"auto-failed actions must preserve unknown-outcome guidance",
);
assert(writeSkillNormalized.includes("Never blind-retry"), "auto-failed external writes must not be blindly retried");
assert(writeSkillNormalized.includes("The out-of-band surfaces hold the confirmation capability"), "approval boundary missing");
assert(writeSkillNormalized.includes("Do **not** call `confirm_action`"), "agent self-confirm prohibition missing");
assert(writeSkillNormalized.includes("Don't blind-retry"), "auto-failed external writes must not be blindly retried");
assert(
writeSkillNormalized.includes("only `draft` carries `confirm_hint`") &&
writeSkillNormalized.includes("never say it is awaiting approval"),
"draft and auto-executed reporting gates must remain distinct",
);
assert(
writeSkillNormalized.includes("`dry_run` suppresses external writes to no-call `[DRY RUN]` drafts"),
"dry-run external-write suppression missing",
);
assert(writeSkill.endsWith("\n"), "generated public skill must end with a newline");
assert(writeSkillNormalized.includes("out-of-band") && writeSkillNormalized.includes("confirmation secret"), "approval boundary missing");
assert(writeSkillNormalized.includes("never call `confirm_action` yourself"), "agent self-confirm prohibition missing");
assert(writeSkillNormalized.includes("call `discard_action`"), "agent-side draft discard path missing");
assert(!writeSkillNormalized.includes("never call `discard_action`"), "draft discard guidance must remain actionable");
assert(writeSkillNormalized.includes("remains approvable"), "expired drafts must not be described as inert");
assert(writeSkillNormalized.includes("A bare `source` drafts nothing"), "source-only action fallback must stay prohibited");
assert(writeSkillNormalized.includes("this tuple is the only call shape"), "source-only action fallback must stay prohibited");
assert(!/act_on_integration[^.]{0,200}request=/.test(writeSkillNormalized), "free-form action request must not return");

// This README is the install instructions for anyone who finds the repo directly rather than the
Expand Down
Loading