agentleFS
getmodern-aigetmodern-ai/graft/AGENTS.md

graft

AGENTS.md9 starsChanged 55 days agoApache-2.0View on GitHub

Guidance for coding agents working in this repository. CLAUDE.md is a symlink to this file. 1. Read CONTEXT.md. Every term is used exactly as defined there, in code, comments, copy and tickets. When a word has an Avoid list, the list is binding. 2. Read the ADRs under docs/adr/ that touch the area you are about to work in. They are the decisions; this file only points at them. 3. Read docs/roadmap.md for what is in scope now and what…

  • Reads credentials
  • Installs packages

The file

We split this AGENTS.md into the rules it gives an agent

10 rules. About 0 are common, 0 are distinctive, and 10 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈0

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 10

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    Read CONTEXT.md. Every term is used exactly as defined there, in code, comments, copy and tickets.

    line 7
  2. Very specific:

    Read the ADRs under docs/adr/ that touch the area you are about to work in.

    line 9
  3. Very specific:

    Read docs/roadmap.md for what is in scope now and what is deliberately later.

    line 11
  4. Very specific:

    Read the pull request body of any ticket you build on.

    line 12
  5. Very specific:

    When a piece here looks like a piece there, the copy is deliberate and the divergence is the tenancy model (ADR 0007) and the approval …

    line 177
  6. Very specific:

    Use a distinct project name (docker compose -p <name> …) to run a second copy beside a colleague's: the sandbox network and the toolbox …

    line 224
  7. Very specific:

    Every other suite runs with fakes and no database — packages/core/src/agent/agent.service.test.ts is the reference shape — and …

    line 256
  8. Very specific:

    Before any result the mount holds one *Waiting for Graft…* line, shipped in ask.html and reported on the page's first run and again after …

    line 501
  9. Very specific:

    Every tool that can ask carries _meta.ui.resourceUri unconditionally (Claude.ai declares no extension): acquire, request_connection, …

    line 512
  10. Very specific:

    Do not run npx shadcn add over them: the registry's base-lyra output is what they replaced, and it would put rounded-none and text-xs back.

    line 1129

≈ “Common” is an estimate: at least 1 in 5 of the 40,246 AGENTS.md files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
AGENTS.md

AGENTS.md

Guidance for coding agents working in this repository. CLAUDE.md is a symlink to this file.

Before anything else

  1. Read CONTEXT.md. Every term is used exactly as defined there, in code, comments, copy and tickets. When a word has an Avoid list, the list is binding.
  2. Read the ADRs under docs/adr/ that touch the area you are about to work in. They are the decisions; this file only points at them.
  3. Read docs/roadmap.md for what is in scope now and what is deliberately later.
  4. Read the pull request body of any ticket you build on; docs/reports/README.md indexes them by ticket. The body is the build record — the contracts later tickets were told to match, what was verified by hand and what was not, the deviations and their reasons — and is where the thing a later ticket trips over is written down. An ADR records a decision; Linear holds the ticket.

Skills

Content lives in .agents/skills/<name>/SKILL.md; .claude/skills/* are symlinks to it, so a skill is authored once and Claude Code and Codex read the same copy. Always edit the file under .agents/, never through the symlink directory.

Presence in skills-lock.json marks the boundary: a listed skill is vendored from an upstream source and pinned by content hash, so a local edit is lost on the next sync. Today every entry is one of mattpocock/skills, all thirty-eight of them, vendored whole under MIT (.agents/skills/LICENSE is his notice; GRA-179). Taking a subset was tried in Cando and abandoned: they cross-reference heavily, so any subset leaves the flows they describe dead-ending on skills nobody installed. A skill of our own would go beside them and stay out of the lockfile; there is none yet, and the Hermes skill under skills/ is a product, not a skill for working here.

Biome does not look inside .agents/skills (biome.json): vendored files answer to their upstream, and in Cando the reformatting reappeared on every sync until the exclusion.

Keeping them current

npx skills update -p -y                  # refresh everything already in the lockfile
npx skills add mattpocock/skills         # pick up skills that did not exist when we last looked

update iterates skills-lock.json, so it can only refresh what is already listed; a skill published upstream after our last add is invisible to it. Three things to know, the first two from Cando's experience of the same CLI and the third reproduced here on 2026-09-22:

  • matt-code-review cannot auto-update. The CLI resolves the upstream skill by its local name rather than by skillPath, so it looks for matt-code-review in his repository, finds nothing, and reports a failure. Nothing is damaged, but it receives no upstream changes. To refresh it by hand, add it under its own name somewhere disposable and copy the file across, then put the name: line back.
  • update -p updates every vendored skill. Run it deliberately, in a pull request about that.
  • On a laptop with ~/.openclaw, update links the skills into the root skills/ directory. The CLI relinks every agent it detects as installed, and its OpenClaw definition names the bare skills/ as that agent's project directory, which here is the Hermes skill's home (MIT, skills/LICENSE). They show up as untracked skills/<name> symlinks; delete them before committing (find skills -maxdepth 1 -type l -delete) and never commit them. Install only ever named claude-code and codex, so a fresh add does not do this.

Which to reach for

Start with /ask-matt. It is the router over the rest and holds the map: which skill opens which flow, where the on-ramps merge, and where a phase boundary makes it safe to compact. Reproducing that map here would let the copy rot. The main path, for orientation:

grill-with-docs → to-spec → to-tickets → implement → matt-code-review
                                           ├ tdd
                                           └ diagnosing-bugs

with /triage on-ramping raw incoming issues, /diagnosing-bugs on-ramping breakage, /wayfinder for an effort too foggy to hold in a single session, and /implement-spec when a whole spec's ticket graph is to land on one branch. One rule from ask-matt worth knowing before you read it: keep grilling, spec and tickets in one unbroken context window (don't compact until after /to-tickets) so all three build on the same thinking; .agents/skills/ask-matt/PHASE-BOUNDARIES.md says where compaction is safe.

Four are here for completeness rather than use: scaffold-exercises and migrate-to-shoehorn target his course repositories, and setup-pre-commit and git-guardrails-claude-code would install tooling this repository has deliberately not adopted (Biome, not Prettier plus Husky); the guard script's substring match also misses git -C <dir> push, so it would not be the protection it says it is (Greptile on #137). improve-codebase-architecture writes an HTML report that loads Tailwind and Mermaid from public CDNs, so the file names and module shape it draws are shown to those scripts when it is opened; fine for this public repository, and worth knowing before running it over a private checkout that links packages/cloud-backings/. /pr proposes a body shape (Summary, Evidence, Merge Danger) that is a fine skeleton, but the body of a pull request here is the build record Before anything else describes, and that content comes first.

Why matt-code-review and not code-review. Claude Code ships a built-in code-review, and the built-in wins the name; his was installed and simply unreachable in Cando. So it is aliased: the directory, the lockfile key and the frontmatter name all say matt-code-review, while skillPath still points at skills/engineering/code-review/SKILL.md upstream. The two answer different questions: the built-in finds correctness bugs and simplifications in a diff and can post inline comments; his checks a branch against documented standards and against the ticket that asked for it, in parallel sub-agents. One consequence: implement, implement-spec and ask-matt end with "use /code-review", which resolves to the built-in. Reach for matt-code-review by hand when you want the spec axis, and do not edit the vendored files to fix it.

These skills assume an issue tracker, and ours is Linear, not GitHub Issues. They default to gh issue create. The real workflow is recorded in docs/agents/ (the Agent skills section below). Read it before using triage, to-tickets, to-spec or wayfinder, or they will reach for the wrong CLI.

Leave "PRs as a request surface" set to no. It is recorded that way in docs/agents/issue-tracker.md. triage step 3 verifies a PR by checking it out and running its tests, which for a PR from outside the organisation means executing contributor-controlled code in a session holding credentials. This repository is public and takes outside contributions, so the flag is load-bearing here in a way it was not in Cando. It cannot be fixed by editing triage/SKILL.md; that file is vendored.

Agent skills

Per-repository configuration the vendored engineering skills read. setup-matt-pocock-skills would write these; here they were written by hand from Cando's (GRA-179), so edit the files directly rather than running it, unless you are switching trackers outright. Agent in that directory means the coding agent; everywhere else it is CONTEXT.md's word.

  • Issue tracker: Linear, in the Graft team, through the MCP server configured in the person's own tooling. Work groups initiative → project → issue; a spec's tickets go in the spec's project as sub-issues. docs/agents/issue-tracker.md, which also carries the "PRs as a request surface" flag, the pull-request rules and the Linear equivalents of the wayfinding operations.
  • Triage labels: the five canonical roles, unaliased: needs-triage, needs-info, ready-for-agent, ready-for-human, wontfix. All exist in the Graft team. They are a state axis and compose with the type labels (Bug, Feature, Improvement, Infra). docs/agents/triage-labels.md.
  • Domain docs: single-context, one CONTEXT.md and one docs/adr/ at the root, despite the monorepo. docs/agents/domain.md.

Working agreement

  • Every pull request has a Linear ticket in the Graft project, and the PR references it.
  • main is protected. Branch, push, open a PR. Merging needs one approving review, a green Typecheck, Lint & Test check, and a clean Greptile review; Greptile reviews every PR and edits its comment in place, so check the SHA it says it reviewed.
  • Every commit is signed off, a merge commit included. git commit -s writes the Signed-off-by line the DCO workflow looks for, git merge --signoff signs a merge, and git rebase --signoff origin/main adds it to a branch already written. A Developer Certificate of Origin replaced the CLA on 2026-09-21 (ADR 0022); the core is Apache-2.0 and CONTRIBUTING.md is what an outside contributor reads.
  • SECURITY.md is what the outside is told. Where to report a vulnerability, what is in scope and where the design is written down; a change that moves one of those boundaries (the proxy, the token, the vault, the sandbox, the asks, the MCP OAuth server, the console's session) updates that file in the same pull request.
  • A comment states the local consequence and points at the ADR for the argument. Do not restate an ADR in a comment; it will rot. A comment that asserts the state of code elsewhere is a claim with a date on it, so name the file or the ticket a reader can check in one step.
  • Consent never moves inside the loop. Secrets are entered in the console, never through a tool argument or a chat. Approvals are the person's. If a change would let Graft's own model enter a credential or answer an approval, stop and read ADR 0004 and ADR 0006. The ask card a chat product renders (ADR 0006 as amended 2026-09-18) answers exactly two asks — the build approval and the keyless connection confirmation — only from an OAuth-connected agent's own ask, through a tool the host hides from the model; read that amendment before touching packages/mcp/src/tools/answer-ask.ts or widening what answerable admits.
  • The approval grain is ADR 0008 as amended on 2026-09-15 and 2026-09-18. Reads never ask; any other tool, destructive included, asks once per agent and the answer holds; asking on every call is the person's opt-in per tool (askEveryCall), both ways. The connection confirmation may record acquire's build approval for the asking agent (approveBuild, on by default): the person answers on the same page, and the grain does not move. Since the amendment of 2026-10-09 (GRA-237) a tool's ask may also allow every tool of its integration for that agent at once (vendor_approval, allowVendor), destructive ones only when ticked; a tool's own deny and askEveryCall still win over it. The connection confirmation offers it too (GRA-239), as a second pre-ticked line under approveBuild, never with destructive tools and never narrowing one that stands (allowVendorWhenConnecting). Read the amendments before changing packages/core/src/approval/approval.decision.ts or the ask in packages/mcp/src/approval.ts.
  • The proxy is the only route to a vendor. A sandbox with any other egress, or a module that holds a credential, violates ADR 0010 and ADR 0013 whatever the reason.
  • No vendor in the open repository (ADR 0002 as amended 2026-09-19): no vendor's client library, configuration variable or id. Define the seam here, with the open form's backing or a no-op, and put the vendor's backing in graft-cloud's private package. Before adding a package or a GRAFT_* variable, ask whether it is a vendor's; if it is, it goes there. The last exception, the hosted form's link provider, moved there under GRA-103.

Lineage

The core is copied from Cando's authored-tools framework and Modern's forward proxy (ADR 0011). When a piece here looks like a piece there, the copy is deliberate and the divergence is the tenancy model (ADR 0007) and the approval grain (ADR 0008). Do not "sync" from Cando; Cando will adopt Graft, not the reverse.

Commands

pnpm install           # Node 24 and pnpm 10; `packageManager` pins the exact pnpm
pnpm run check         # biome format + lint, writes fixes
pnpm run lint          # biome ci: what CI runs, no writes
pnpm run check-colours # the console: a hard-coded colour, a colour literal in @theme, a mode-orphaned token (ADR 0017)
pnpm run check-tokens  # the console: every design-token utility compiles into apps/web/dist — build first
pnpm run check-types   # turbo: tsc per package
pnpm run test          # turbo: vitest per package
pnpm run build         # turbo: only packages that declare a build script
pnpm run dev           # turbo: persistent, only packages that declare a dev script
docker compose up -d   # the self-hosted form, whole: Postgres, Graft (server, proxy, MCP, console), the sandbox image
pnpm run db:start      # postgres:18 alone, plus docker-compose.dev.yml, on 127.0.0.1:5432 (GRAFT_POSTGRES_PORT overrides)
pnpm run db:push       # apply packages/db/src/schema/*.ts directly — the dev loop
pnpm run db:generate   # write a migration under packages/db/drizzle from the schema
pnpm run db:migrate    # apply the committed migrations
pnpm run db:check-chain # journal ↔ files ↔ prevId chain, no database — the third of three guards
pnpm run db:studio     # Drizzle Studio

The compose file is the development environment

docker-compose.yml at the root is the self-hosted form (ADR 0002) and, from GRA-33 on, the way this repository is run whole: docker compose pull fetches the two published images, and docker compose up -d brings up Postgres and runs the server image — server, proxy, MCP endpoint and console in one container — with migrations applied and an admin opened on first start. --build builds this checkout's Dockerfiles over the tags instead (GRA-194). README, "Self-hosting", is the walkthrough; the compose file's comments are the reference for each name. Two ways to use it while developing:

  • Postgres alone (pnpm run db:start) and the server from source on the host (pnpm run dev) — the inner loop, where tsx watch and Vite reload. apps/server/.env names the database for it. The four db:* compose scripts pass -f docker-compose.yml -f docker-compose.dev.yml, because that overlay is the only thing that publishes Postgres on the host, and on loopback alone (GRA-148): docker compose up -d publishes no database, and the graft service reaches it by name on the project's own network.
  • Everything in containers (docker compose up -d --build) — to see the image a self-hoster gets, or to run the loop end to end with Docker sandboxes and no host setup. .env at the root (from .env.example) holds its secrets, model key and admin. No secret has a default, in that file or in the compose file: node dist/keys.mjs mints the five Graft refuses to start without and GRAFT_ADMIN_PASSWORD with them (GRA-148), and the operator types GRAFT_ADMIN_EMAIL.

Use a distinct project name (docker compose -p <name> …) to run a second copy beside a colleague's: the sandbox network and the toolbox volume are named after the project, so two never share one.

The database

Postgres in both forms (GRA-1), Drizzle in packages/db. drizzle-kit is that package's dependency and not a root one — every root db:* script filters to @graft/db for that reason, and pnpm exec drizzle-kit at the root fails with "command not found". drizzle.config.ts reads GRAFT_DATABASE_URL from apps/server/.env, the one file that names the development database for the app and the CLI alike. db:push is for development; db:generate + db:migrate when a change has to be versioned.

Three guards, each catching what the other two cannot: drizzle-kit check finds a fork (two snapshots sharing a prevId), drizzle-kit generate producing nothing means the schema matches the tip, and db:check-chain (packages/db/src/migration-chain.ts) finds a hole — a journal entry whose .sql is absent, a snapshot missing from the middle, a prevId pointing at nothing. CI runs all three as their own steps.

The four Better Auth tables in packages/db/src/schema/auth.ts are generated by pnpm --filter @graft/auth generate-schema, not hand-written; only the file's header comment is ours, so put it back after a run. The CLI is the auth package since Better Auth 1.7 (@better-auth/cli stopped at 1.4.21), published in lockstep with the library, and the script runs the version matching the installed better-auth, so the generator and the runtime cannot drift. Regenerate rather than edit when the auth configuration or that version changes, then db:generate: Better Auth since 1.7.3 validates the schema at init and refuses a required column it never writes (GRA-86, migration 0008).

Two suites need Postgres: apps/server/src/database.integration.test.ts, which migrates a fresh throwaway database per run, and apps/server/src/mcp-oauth.integration.test.ts, which drives the MCP SDK's client with an OAuthClientProvider through the whole OAuth flow over another (ADR 0018). Each skips without TEST_DATABASE_URL and refuses to skip under CI, where the workflow runs a postgres:18 service. Locally, pnpm run db:start and TEST_DATABASE_URL=postgresql://postgres:password@localhost:5432/graft pnpm --filter @graft/server test. Every other suite runs with fakes and no database — packages/core/src/agent/agent.service.test.ts is the reference shape — and packages/db's suites pin the rendered SQL, which is where the rule that every agent-scoped read takes the scope in the statement (ADR 0007) is asserted. The scope itself is one such statement since ADR 0007's amendment of 2026-09-19 (GRA-105): listScopeConnectionIds in packages/db/src/repo/agent.ts resolves an agent's scope_mode — all, every connection of the person's, or listed, the rows in agent_connection — under the person and both ids on each branch, and getAgentScope is that read; every mint of a capability token goes through it, so the token names ids under either mode.

Running the server locally

pnpm run db:start
pnpm --filter @graft/server keys >> apps/server/.env    # key pair, keyring, auth and handoff secrets, and the admin's password
cat >> apps/server/.env <<'ENV'
GRAFT_DATABASE_URL=postgresql://postgres:password@localhost:5432/graft
GRAFT_AUTH_URL=http://localhost:3000
GRAFT_CORS_ORIGIN=http://localhost:3001
GRAFT_CONSOLE_URL=http://localhost:3001
ENV
pnpm --filter @graft/server dev

The server migrates on start (apps/server/src/boot.ts, GRA-33): before it listens it checks the committed chain for holes — a hole refuses the start with the problems listed — and applies whatever the database has not seen, so db:migrate is no longer a step. The exception is the db:push loop: a pushed database has no migration ledger and the migrator would refuse to create tables that exist, so set GRAFT_MIGRATE_ON_START=false while the schema is moving. Then, if GRAFT_ADMIN_EMAIL and GRAFT_ADMIN_PASSWORD are set (all-or-nothing) and the database holds no person, the boot opens that account through Better Auth's own sign-up and prints one line saying so; a database with anyone in it is never touched, and the line says that instead. Unset, nothing happens — a laptop registers at /signup and verifies the address from the link the console transport prints (ADR 0020, ADR 0021). The password is the keys script's, never a file's: @graft/env refuses one under 16 characters, one this repository once shipped and one still holding a secret store's placeholder, each with a sentence naming the variable (GRA-148).

GRAFT_DATABASE_URL, GRAFT_AUTH_SECRET (32+), GRAFT_AUTH_URL, GRAFT_CONSOLE_URL (where the console answers — the base of every handoff URL) and GRAFT_HANDOFF_SECRET (32+, signs those URLs) are required, and so is GRAFT_KEYRING_SECRET (32+) under the default GRAFT_BACKINGS=open; GRAFT_CORS_ORIGIN is an optional comma-separated list of origins; the capability token key pair is all-or-nothing; GRAFT_DEV_SEED layers a JSON file of connections over the database for a proxy smoke test and is refused in production. A refusal prints one line per problem with the variable named and exits 1 (packages/env/src/server.ts). Under NODE_ENV=production on the open backings the model group ADR 0014 requires — GRAFT_MODEL_BACKEND=provider with GRAFT_MODEL_PROVIDER and GRAFT_MODEL_API_KEY; GRAFT_MODEL_AUTHORING and GRAFT_MODEL_TRIAGE are optional beside them and default per provider — is required too; the fields and that rule are GRA-31's (the section Running acquire locally below). GRAFT_APPROVAL_WAIT_SECONDS (default 25) is how long a tool call waits for a person to answer a handoff before returning awaiting_approval — or awaiting_connection / awaiting_credential for the two connection handoffs (GRA-28), which share the wait and the TTL — and GRAFT_PENDING_ACTION_TTL_HOURS (default 24) how long that action stays answerable (ADR 0006, ADR 0008). GRAFT_CARD_HOSTS (default claude.ai,chatgpt.com) names the chat products whose OAuth clients may answer the ask card, by the host of their registered redirect URIs, and is the whole of that rule (GRA-84, GRA-150; the paragraph on the card below). GRAFT_PROXY_MAX_BODY_BYTES (default 10 MiB, floor 1 MiB) is the proxy's body cap on both legs, passed into the proxy's options beside followRedirects and named on the boot line only when raised (GRA-183; ADR 0010). packages/env/src/schema.ts is the rules as code.

An OAuth consent (ADR 0005) adds no variable, two server routes and one console route. GET /api/oauth/redirect-uri is GRAFT_AUTH_URL plus /api/oauth/callback, computed by one function (@graft/core's oauthRedirectUri) that both the form and the callback's mount read, so the URI shown is the URI served in both deployment forms — a person registering a Google client pastes it as the redirect URI. GET /api/oauth/callback takes the vendor's code and state with no session: the state is an HMAC under GRAFT_HANDOFF_SECRET over the connection, the person and the ask, so the browser that arrives from the vendor carries its own authority. It renders nothing (GRA-48): once the tokens are stored — or the state, the vendor or the exchange refused — it redirects to the console's /oauth/callback under GRAFT_CONSOLE_URL with status, connectionId and message in the query and never the code, the state or a token; apps/web/src/routes/oauth.callback.tsx draws the outcome in Cando's empty-state shape, tells the waiting console over postMessage at its own origin and the graft:oauth BroadcastChannel, and closes itself after 1.5 s on success. The writer and the reader of that query are one browser-safe file, @graft/core's oauth.rules.ts, and the popup contract — the console's awaitConsent reading the message from its own origin — is exercised on both sides in apps/web/src/lib/oauth-consent.test.ts. apps/server/src/oauth.ts is the second place this server decrypts a credential — the client secret, for the code exchange — beside the proxy binding in app.ts; @graft/core still takes the vault's encrypt half only. The token refresh runs in the proxy's scheme plugin, single-flight per connection, and stores the rotated record through ProxyDeps.storeCredential (apps/server/src/connections.ts binds it); a refused refresh passes the vendor's 401 through and marks the connection for re-consent. An oauth_authorization_code connection keeps its client id, endpoints and scopes in scheme_config and the client secret and the issued tokens in one credential_ciphertext; the five oauth_* columns from GRA-6 other than oauth_refresh_state are unwritten and await a drop migration.

GRAFT_BACKINGS picks the backing behind each seam (ADR 0002; apps/server/src/backings.ts). open, the default, is what this repository holds — the sandbox GRAFT_SANDBOX_BACKEND names, the local keyring, a mirror that copies nothing — and is also the self-hosted form in production. cloud loads the hosted form's backings from a private package that is not in this repository: it is placed at packages/cloud-backings/, which is gitignored, where the workspace glob picks it up and apps/server's optionalDependencies entry links it into the server's node_modules; absent, the install still succeeds and the server refuses to boot with a sentence saying so. The selector imports it by a name held in a variable, so the type program never resolves it — which is what keeps the package absent rather than optional.

A connection comes from a provider, and the providers ride the same selector (ADR 0019, GRA-57). Backings.providers is an ordered list — under open the keyring alone (the gateway ahead of it when configured), under cloud the gateway when configured, then whatever the private package answers, with the keyring appended last — and the boot line names them (providers keyring). A provider (packages/core/src/connection/provider.ts) decides how a vendor gets connected (form over the proxy's schemes, link, or none), how a call resolves (inject the row's credential, or relay through an upstream that holds it), and what to release on revoke; request_connection routes a proposal to the first provider that covers it — a none-scheme proposal past every link provider, to the keyring's keyless confirmation (GRA-166) — and the proxy's connection read (apps/server/src/connections.ts) asks the row's provider how the call resolves. Coverage is async (GRA-126): covers(vendor, hosts) and a link's target answer a promise, because a hosted provider decides coverage by asking its vendor's catalogue — which vendors it connects and at which hosts — and caches the answer; the keyring and the gateway resolve at once, and providerFor awaits the providers in order, asking nobody after a yes. The relay engine is packages/proxy/src/relay.ts: a relay plugin rewrites the resolved vendor request into the upstream's under RelayHeaderRules as data, and relay.test.ts drives it through an in-process upstream. There is no catalogue of plugins: the proxy takes the plugin from the connection's resolution (ProxyRelay.plugin), the gateway's from gateway-relay.ts (GRA-58) and a hosted provider's from beside itself in the private package. RELAY_SCHEMES names two: gateway, and the generic relay every other relay provider's rows carry (GRA-103; migration 0010 moved the rows the first hosted relay provider wrote under its own name onto it). A row of a relay provider records its relay scheme in the scheme column, and the enum pin in packages/core covers both lists — so connectionScheme carries gateway and relay, which no form, proposal or credential entry accepts (connection.rules.ts refuses a relay scheme with a sentence). With the keyring alone nothing observable changed. The gateway is routed to first: an operator's explicit host list wins over a broker's catalogue (environmentProviders in apps/server/src/backings.ts). A hosted provider comes from the private package whole — its client, its relay plugin, its provider, its variables and its fakes (ADR 0002 as amended 2026-09-19; GRA-103) — and the open suites that exercise a link's two ends, the card, the relay rung and the revoke's release drive packages/core/src/connection/testing/fake-link-provider.ts instead: a ConnectionProvider of kind link whose coverage is the test's function, whose rows carry relay, whose start mints a link on a fake origin, whose complete answers the next account the test connected (connectAccount), and whose resolve relays to an in-process upstream. The copy the card and the console draw names a provider by its name, never a vendor.

The gateway provider is the environment's (ADR 0019 as amended 2026-09-17, GRA-58): the GRAFT_GATEWAY_* group — covered hosts, upstream URL, the identity header's name and value, an optional caller-header prefix — all-or-nothing and off by default, read by gatewayProviderFrom in apps/server/src/backings.ts, which puts the provider first in either form's order. A proposal every host of which it covers connects with no person step: request_connection makes the row (registerProviderConnection, scheme gateway, no credential) and grows the asking agent's scope in one transaction (a no-op for an agent on all, GRA-105); a row the person revoked or has not given this agent is refused with the console step that would grant it, and a narrower in-scope gateway row is widened to a later proposal's hosts within the coverage. The relay carries the vendor URL in the path, <upstream>/<host>/<path>, and the provider brings the relay leg its own fetch with the gateway's hostname exempt from the resolver's private-address rule (createUpstreamFetch({ unguardedHosts }), on ProxyRelay. upstreamFetch) — the proxy's shared fetch keeps the full guard, so a vendor host spelling the gateway's name is still judged on its address. A revoked gateway row comes back through the console's Reconnect (POST /api/connections/:id/reconnect), the one row kind with nothing to re-enter. A fake gateway on a loopback port stands in for a company's in packages/proxy/src/gateway-relay.test.ts and apps/server/src/app.test.ts; on a laptop, GRAFT_GATEWAY_UPSTREAM_URL may be plain http (refused in production).

In the image the package arrives built (GRA-38). The bundled server runs where there is Node and node_modules and nothing else — no TypeScript, no workspace — so a linked package ships a build of its own: tsdown, the @graft/* seam packages it imports inlined as the server's bundle inlines them, its third-party imports left external; and its exports name dist/index.mjs under default for the runtime and src/index.ts under types for the type program. apps/server/Dockerfile runs that build when packages/cloud-backings/ is present in the build context and copies dist/ and package.json to /app/node_modules/@graft/cloud-backings, a real directory beside the dependencies the package imports; absent, the same lines do nothing, and the Dockerfile's comments say why each is shaped as it is. One consequence on a laptop: tsx resolves the bare name through default exactly as Node does, so a server run from source under GRAFT_BACKINGS=cloud needs pnpm --filter @graft/cloud-backings build first — unbuilt, the import fails as module-not-found and the selector's sentence says the package is not installed.

The MCP endpoint is POST /mcp with Authorization: Bearer <agent token> — POST /api/agents mints the token, shown once. A chat product connects over MCP OAuth instead of a pasted token (ADR 0018, GRA-53): /mcp's 401 carries WWW-Authenticate: Bearer resource_metadata="…/.well-known/oauth-protected-resource/mcp"; /.well-known/oauth-protected-resource (both forms RFC 9728 admits) and /.well-known/oauth-authorization-server answer at the origin's root from apps/server/src/mcp-oauth.ts, mounted above the console's SPA fallback, which excludes the prefix; registration, authorization, token and revocation are /mcp/oauth/{register,authorize,token,revoke}, CORS-open and outside /api. The authorization endpoint judges the request and sends the browser to the console's /consent route with the request as its query — a client or a redirect URI in doubt lands there as a refusal rather than being redirected anywhere — and the console's GET/POST /api/mcp-oauth/{request,consent} describe and decide it with the person's session. The decision mints the agent (createAgentForClient: no static token, connected_via_client_id/_name recorded, on every connection of the person's unless the consent's scopeMode: "listed" limits it to connectionIds — ADR 0007 as amended 2026-09-19) or names an existing one and binds an authorization code to it. Tokens are opaque and hashed in mcp_token (@graft/db/repo/mcp-oauth); requireAgent dispatches on the prefix — grft_ static, grfta_ access token — and both resolve to one agent, so nothing past the door knows which arrived. Access tokens live an hour; refresh tokens rotate — one successor per predecessor, the claim and the pair one transaction — and live with the agent; a rotated token presented again within thirty seconds is answered the same pair, opened from a seal on the retired row that only the retired token can open (mcp-oauth.replay.ts), and past thirty seconds it is a replay that revokes the grant; revoking the agent revokes every token in the same transaction. No variable is added: the issuer is GRAFT_AUTH_URL's origin and the consent page is under GRAFT_CONSOLE_URL. The rules and the service are packages/core/src/mcp-oauth/; the two suites are packages/core/src/mcp-oauth/mcp-oauth.service.test.ts over fakes and apps/server/src/mcp-oauth.integration.test.ts over Postgres with the SDK's own client. A client the deployment does not vouch for is drawn as its own claim (GRA-175; ADR 0018 as amended 2026-09-22): the description's rendersCards is the vouching signal too, since a registrant picks client_name, client_uri and logo_uri freely but cannot claim a callback on a GRAFT_CARD_HOSTS host, and where it is false the consent card shows the unknown-app Alert with the callback host on a line of its own, titles itself Connect X, as it calls itself, to Graft, and starts the scope at listed with nothing ticked, leaving the first connection to the scope ask. The verdict is one tested function, apps/web/src/lib/consent-client.ts, and POST /api/mcp-oauth/consent still takes either scope the person chooses: the change is the default, not a restriction. A session this process no longer holds is re-opened for a chat product's client (GRA-129; ADR 0018 as amended 2026-09-20): a grfta_ request with an unknown session id, or with none and no initialize, gets a session under that id (or a new one, on the response), primed by a synthetic initialize with no client capabilities — Claude's card frame keeps its pre-deploy id and drew "Unable to reach Graft" on the spec's 404; a static-token agent still gets the 404 and 400 (packages/mcp/src/http.ts, pinned in apps/server/src/mcp.test.ts). A fresh initialize announcing a protocol version the SDK lacks negotiates instead of being refused (GRA-162; ADR 0018 as amended 2026-09-22): the door drops the MCP-Protocol-Version header on a session-less initialize when the SDK does not speak it, so the body's version negotiates to Graft's latest — Claude.ai has announced 2026-07-28 on every session since 2026-09-20 — and on both of GRA-129's re-open paths, where the re-opened session negotiated for the client (GRA-232: Claude opens with a session-less tools/list, and the 400 outlived #128 by two weeks); a request that names a live session keeps the SDK's 400. Under open, authored code runs on the backing GRAFT_SANDBOX_BACKEND names: docker by default, which needs the GRAFT_SANDBOX_IMAGE/GRAFT_SANDBOX_NETWORK pair below and, unset, leaves the server up with every run refusing for want of a sandbox; or fake, a temporary directory on the server's own disk for a laptop without a daemon — the toolbox then lives in that directory too, for as long as the process does — which is not a sandbox, and @graft/env refuses it in production and beside cloud.

A chat product's agent lists no authoring tool and no execute__ tool (GRA-125; ADR 0004 as amended 2026-09-20). packages/mcp/src/by-hand.ts judges the agent once per session by its connected_via_client_id: a static-token agent (Hermes, OpenClaw) lists the whole set; an agent a chat product holds over OAuth lists the meta-tools and its promoted tools, and a call to a hidden tool is refused advanced_tools_hidden. find_tool answers connections — every live connection in the agent's scope with the connectionId acquire takes — which is where such an agent learns one.

The initialize result carries the playbook, and the descriptions carry none of it (GRA-54, GRA-111). SERVER_INSTRUCTIONS in packages/mcp/src/session.ts is what a client that loads no skill — Claude.ai, ChatGPT, a bare MCP client — shows its model, and the one place on the wire a rule of conduct lives: the order of operations, the handoff and secrets rules, the keyless and rotation rules, the build approval on the connection page, run_tool for a client that snapshots its list, the approval grain, and whose tool answer_ask is. Every tool description (tools/meta.ts, tools/authoring.ts, tools/execute.ts, tools/answer-ask.ts) is a capability statement in the third person — what the tool does, its arguments, its answer shapes, the awaiting_* shapes and their url included — opening with when it is used, stated as a fact and not as an instruction: ChatGPT's classifier badged GRA-54's rule-bearing descriptions "Suspicious Instruction" on every call, and both hosts' published guidance puts behaviour in instructions (the research comment on GRA-111 has the sources). INSTRUCTIONS_BUDGET is 2,048, Claude Code's documented per-server cap on both fields (its CHANGELOG, 2.1.84), and the order of operations sits in the first 512 characters, OpenAI's front-loading rule. packages/mcp/src/session.test.ts pins the shared sentences to skills/hermes-graft/SKILL.md, each description to its opening sentence, every fixed tool's whole definition against a denylist of conduct markers (never, do not, always, you), and every description to the same 2,048. A rule belongs in the instructions and the skill; a fact about what a tool answers belongs in its description; re-run the live check the ticket records if the order of operations moves.

The ask card is an MCP App a chat product renders in place of the handoff link (GRA-84; ADR 0006 as amended 2026-09-18). packages/ask-card (@graft/ask-card) is one HTML page — plain TypeScript over @modelcontextprotocol/ext-apps's app-with-deps, no React — that vite build with vite-plugin-singlefile inlines whole into dist/ask.html; hosts block external scripts and styles, so nothing may be linked. The card reports its own frame height (GRA-275): autoResize is off, and every render and every layout change of the mount writes the height both as ui/notifications/size-changed and on <html>, which is what Claude.ai reads (anthropics/claude-ai-mcp#69); a result with no card reports 0, so the host draws no empty frame under a find_tool or run_tool that asked nothing (src/frame-size.ts, src/card-view.ts). Before any result the mount holds one Waiting for Graft… line, shipped in ask.html and reported on the page's first run and again after connect(), so a call that waits on an approval shows that line rather than Claude's blank 150 px default; the tool-input notification keeps it (GRA-335). A line rather than 0, because a zero-height frame is the one the card would have to grow out of nothing, and an ask hidden there is one the model was told not to relay. Its stylesheet carries a copy of the console's cando:tokens block and src/bundle.test.ts fails on a colour literal outside the :root/.dark rules (ADR 0017 over one file). The MCP server (packages/mcp/src/ask-card.ts, session.ts) declares resources, lists the one resource ui://graft/ask (text/html;profile=mcp-app, an empty _meta.ui.csp said outright: the card fetches nothing) and serves the page from @graft/ask-card's ASK_CARD_HTML_PATH, which resolves to packages/ask-card/dist/ask.html in a checkout and to dist/ask.html beside the server's bundle, where apps/server/tsdown.config.ts copies it and the Dockerfile's build stage builds it first. Every tool that can ask carries _meta.ui.resourceUri unconditionally (Claude.ai declares no extension): acquire, request_connection, request_credential, and — since a host renders a card only for a tool whose definition names the resource (GRA-116's live check, 2026-09-20) — run_tool, every execute__<id> and every authored tool in the list, whose first write answers the tool ask, and find_tool, whose answer may carry the Setup offer's card (GRA-210, the paragraph on it below); ChatGPT's alias openai/outputTemplate rides beside it, and the resource carries the openai/widget* aliases of its ui keys (GRA-112); their awaiting results carry the card's data under structuredContent.card beside GRA-55's unchanged url, message and reason (result.ts's withCard). For a client the server knows renders the card, the awaiting message takes its card form and cardShown: true rides beside url (GRA-120; ADR 0006 as amended 2026-09-20): packages/mcp/src/card-client.ts's clientRendersCards is the card gate's client half — an OAuth agent whose client registered every one of its redirect URIs on a GRAFT_CARD_HOSTS host, which is the whole rule since GRA-150 (ADR 0006 as amended 2026-09-21: a client writes its own initialize, so its declaration of the MCP Apps extension admits nobody and is only observed, on each tool call's wide event) — held once per session, and its toolAskResult is where every awaiting result goes onto the wire (tools/meta.ts, tools/execute.ts, tools.ts); each ask flow writes both forms through handoff-message.ts, so the console form a static-token agent or an unvouched client reads is byte for byte what it was, and url never changes. SERVER_INSTRUCTIONS carries the one clause on what cardShown means; the Hermes skill does not, since a Hermes agent never receives it, and session.test.ts records that. An awaiting result is not an MCP error: every awaiting_* answer returns through result.ts's toolAwaiting with isError: false and the same JSON, because a host renders no view for an error result (ext-apps issue 694) — refusals and failures stay isError: true. The card answers by calling answer_ask (tools/answer-ask.ts), declared _meta.ui.visibility: ["app"] so the host hides it from the model; the tool refuses a static-token agent, an OAuth client whose hiding is not established (not every registered redirect URI on a GRAFT_CARD_HOSTS host), another agent's ask, a closed or expired ask, and every ask but the build approval, the tool's first-use approval (GRA-116), the keyless connection confirmation, the scope ask and a link provider's decline, and records the rest through ask-answer.ts — the same functions the console's POST /pending-actions/:id/answer and /connection call, with via: "card" on the answer. The gate is tools/card-gate.ts, shared with the two other app-only tools. The consent page says which way a client will go before the person connects: GET /api/mcp-oauth/request answers rendersCards, read from the same function over the same parsed list (apps/server/src/mcp-oauth.ts), and components/agent/consent-card.tsx shows one sentence when it is true. packages/mcp/src/answer-ask.test.ts is the suite; pnpm --filter @graft/ask-card build before pnpm --filter @graft/mcp test on a fresh checkout, or let pnpm run test order it.

Every ask settles in the card; the console is a popup for the secret alone (GRA-116, GRA-117, GRA-118; ADR 0006 as amended 2026-09-19). Three more tools and one query. A provider that cannot start its link steps aside (GRA-147; ADR 0019's bullet of 2026-09-21): mintProviderLink rewrites the open ask onto the keyring (providerConnect: form, a note, providerFallback) through PendingActionDeps.updatePendingActionPayload, the console's button answers { fallback: "form" } and its card re-reads as the form, start_link answers card_not_available pointing at that form, and routeProposal words a repeated call for the ask as it stands (asked), not as it would route. The two link routes count provider_link_started, provider_link_fell_back and provider_link_returned. start_link { pendingActionId, approveBuild } → { url, expiresAt, provider } (tools/start-link.ts, app-only, the card gate) mints a link provider's Connect Link for the agent's own open connection ask through packages/mcp/src/provider-link.ts's mintProviderLink — the function POST /api/pending-actions/:id/link now calls too, so the two doors issue one link — with the build choice signed into the state and from=card on the return URIs, which apps/server/src/provider-link.ts's return route copies onto its console redirect; McpDeps.authUrl is GRAFT_AUTH_URL for it. ask_status { pendingActionId } → { state, sentence } (tools/ask-status.ts, app-only, read-only, the gate less the open check) reads where the ask stands off the row — open, answered, declined, expired — with the console's settled sentence for the kind. The card (packages/ask-card/src/render.ts) opens every page it sends the person to with from=card (withFromCard; ASK_STATUS_POLL_MS is 3 s) and polls ask_status until the ask is settled or the render is abandoned: Sign in with <integration> on a link ask, Open in Graft on a scheme with a credential or a credential re-entry, Open in the console under a card_not_available refusal or where the host refused ui/open-link. The card asks the handoff page's questions with its choices (GRA-316): Full access / Ask me first on a keyless connection, a link ask and a scope ask (the scope ask's yes is { allow, approveBuild, allowVendor } on answer_ask, never destructive), and on a tool ask Every <integration> tool, Just <tool> and the destructive line, with no allow for one call. Its words are packages/ask-card/src/copy.ts, a copy of the console's access-choice.ts, tool-allow-choice.ts and handoff-copy.ts helpers, held to them by apps/web/src/lib/ask-card-copy.test.ts. The console reads the flag through @graft/core's browser-safe connection/card.rules.ts (openedFromCard, askAnsweredMessage, FROM_CARD_CLOSE_MS): /pending/:id?from=card posts graft:ask to its opener and closes itself 1.5 s after a successful submit (since GRA-144 every link visit closes itself, the card's alone announces first — lib/handoff-page.ts), /link/callback?from=card closes on a success and stays on a failure (the ask is still open, and the card is where the person tries again), and neither behaves differently without it. @graft/ask-card/shape spells from=card a second time, import-free, and ask-card.test.ts pins the two spellings together.

A connection the person holds but this agent was not given is the scope ask (GRA-104; ADR 0006 as amended 2026-09-19). request_connection's match against the person's rows (existingConnectionFor in packages/mcp/src/connection-request.ts, GRA-76) answers connected for a usable row in scope and connection_exists for a revoked row or one whose credential is missing; a live, usable row made for another agent is a pending action of kind scope — payload connectionId, vendor, displayName, provider, primaryHost, hosts, scheme, docsUrl; the connection_id column set, so a revoke closes it — and the call waits and polls as the connection ask does, answering awaiting_scope with url, message, pendingActionId, expiresAt, connectionId and provider. One open ask per agent and connection. The console's card is apps/web/src/components/pending/scope-ask-card.tsx — Allow, Decline and GRA-75's build choice, pre-ticked — and posts the generic POST /api/pending-actions/:id/answer with { allow, approveBuild? } (AnswerBody, exported from apps/server/src/api.ts for the console); recordApprovalAnswer in ask-answer.ts grows the scope (addConnectionToAgentScope) and grants the build approval in the answer's transaction, and leaves the answer for the agent's next call, which answers connected naming the execute tool, or scope_declined. The ask card renders card.kind: "scope" as answerable, and answer_ask admits { allow }, { allow, approveBuild } or { allow, approveBuild, allowVendor } for it under the same gate (the last since GRA-316). SERVER_INSTRUCTIONS names scope in its handoff list, and session.test.ts pins the word across the description and the Hermes skill.

A keyless proposal for a vendor the person already holds widens that row (GRA-167; ADR 0006 as amended 2026-09-22). When existingConnectionFor finds no row covering a none-scheme proposal but the person has a live, usable keyring row of the vendor on none in this agent's scope, the verdict is widen and askToWiden makes a connection ask about that row: the row's primary host and name, hosts grown to the union, widens: { connectionId, addedHosts } on the payload, the connection_id column set. confirmConnectionAsk reads widens and calls @graft/core's widenKeylessConnectionHosts instead of registering a row; the agent's next call answers connected ("Confirmed. … now reaches …"). The console's card and the ask card draw the added hosts and no form. A keyed or revoked row, or one outside the scope, is never widened.

A person's connections reach every agent of theirs by default; scope is a narrowing the person opts into (GRA-105; ADR 0007 as amended 2026-09-19). agent.scope_mode is all or listed (agentScopeMode in packages/db/src/schema/agent.ts; migration 0009 added the column as listed for every existing row and moved the default to all). AgentOutput.scopeMode is on the wire; GET /api/agents/:id answers connectionIds as the scope resolves under the mode; POST /api/agents and the consent's agent: { kind: "new" } take scopeMode (default all) and refuse connectionIds beside all; PUT /api/agents/:id/scope takes { mode: "all" } or { mode: "listed", connectionIds? } (ScopeBody, exported for the console), a listed write with no list materialising the scope as it stood. In @graft/core, addConnectionToAgentScope is a no-op for an agent on all, which is what makes every grant-on-connect path right without reading the mode, and listAgentIdsForConnection — the agents a revoke announces to — takes every agent on all. An agent on all never reaches the scope ask above: existingConnectionFor finds every usable row in scope and answers connected. The console draws the mode as a Select — All connections first, Selected connections revealing the picker — through components/agent/scope-mode-field.tsx in the create dialog and the consent card; the labels and the write's body are src/lib/scope-mode.ts, tested. One connection is turned on or off for one agent by its own write (GRA-294): PUT and DELETE /api/connections/:id/agents/:agentId are addConnectionToAgentScope and removeConnectionFromAgentScope, each under the agent's row lock in one transaction, answering AgentScopeToggleOutput; a removal from an agent on all materialises listed without that connection, so later connections stop arriving, and answers materialised: true. Every scope write but a grant to an agent on all tells the agent's session after the commit. The agent page's switches are src/lib/scope-toggles.ts (turnLater, turnConnection), tested against the server's model.

A tool follows its vendor's reconnected connection (GRA-122; ADR 0007 as amended 2026-09-20). authored_tool.default_connection_id is the row the tool was authored against, and a run resolves the connection per agent (packages/mcp/src/run.ts): the default when this agent holds it live; otherwise — revoked, or a live row another agent of the person's holds and this one was never given — the one live, usable connection of the tool's vendor in this agent's scope. The row is rebound only when its default is revoked, dead for every agent, through @graft/core's rebindToolIfConnectionDead, which reads the default's row locked (ToolDeps.findConnectionForUpdate) and writes only if it is still dead, so a reconnection landing meanwhile wins; a live default outside the scope is another agent's and stays. With no such connection the connection_revoked or connection_not_in_scope refusal stands as before, and with several it names them under alternatives and says the step is the person's (revoke.ts's revokedConnectionRefusal, run.ts's notInScopeRefusal). A caller that names the connection (AuthoredRunArgs.connectionId) is never followed: acquire's dry run passes the job's connection, so a version published onto an existing tool row is proved against the connection the job authored it for; a publish onto an existing tool under activate: false rebinds the row to defaultConnectionId through the same function, in the publish's transaction, only when the row's default is missing or revoked — a live default waits for the pass, so a failed job leaves the tool where it was (packages/publish, step 8). The job's "did not run" progress line and tried[].summary carry a refusal's reason: message rather than the word refused. packages/mcp/src/server.test.ts (the GRA-122 describe), acquire.test.ts and publish.service.test.ts are the suites.

A stock tool is a ready-made tool, copied into the toolbox the first time it is reached for (GRA-238; ADR 0025). packages/stock (@graft/stock) is the workspace: tools/<vendor>/<name>/ holds the module, manifest.json (name, description, input schema, hosts, the annotations the check must agree with) and test-input.json; src/workspace.test.ts pins the shape (a starter vendor, hosts among the starter's) and src/harness.test.ts is the harness (below), and the package's tsconfig leaves tools/ to the check, which types each module against its own schema. The boot loads it after the migrations (boot.ts's loadStockOnStart, @graft/core's loadStockCatalogue) into the global catalogue, stock_tool and stock_tool_version (migration 0014): no person, no owner tier, a version appended by number when the directory's hash is one the tool has at no number (so a replica of an older release booting mid-deploy appends nothing, and a revert ships as a change), with its files, hosts and the check's result, under one advisory lock; repo/stock.ts's reads are unscoped and pinned by name in repo/scope.test.ts. The image carries the workspace as apps/server/tools/ (tsdown.config.ts, the Dockerfile). McpDeps.toolSource (tool-source.ts) lists, describes and copies; find_tool searches stock beside the toolbox at tiers 2 (a matching connection in scope, connectionIds) and 3 (none, connect: the starter's request_connection arguments), with stock: true, a person's tool of the same name hiding it. stock-copy.ts's ensureToolForAgent is the one road in, which runAuthoredTool (so run_tool), apps/server/src/tool-run.ts (the console's run, before its read-only and working-set rules) and promoteToolForAgent (so promote, and a console route) take: the person's tool, or the stock version copied by @graft/publish's copyStockVersion as an ordinary tool whose version records stock_tool_id and stock_version_id, bound to the matching connection in scope, or connection_needed with connect. Every write of a version has a directory of its own (@graft/toolbox's writePath, tools/<vendor>/<name>/w-<writeId>; GRA-238, GRA-265): a publish and a stock copy write their files there with nothing held (the publish's install included), then write the rows in one short transaction under ToolDeps.lockToolName (repo/tool.ts's lockAuthoredToolName), where the version number is decided as the tool's next and tool_version.path records the directory. So no writer ever writes over another's files, two publishes of one tool racing take two numbers, two first copies (or a copy and a first publish) make one tool and the later answers it, a unique-constraint loser answers the winner's row, and a directory whose rows never land (a refused or failed publish, a lost race) stays as an orphan, since nothing under tools/ is removed (ADR 0009). Versions written before this are v<N> (versionPath) and stay valid; packages/toolbox/README.md has the layout. The mirror is asked for a copy's version as a publish asks it. A connection matches by its hosts (GRA-241): packages/mcp/src/stock-match.ts is the pure decision, every manifest host among the connection's as the proxy's hostSetOf reads them, any provider, the vendor slug breaking a tie; find_tool's connectionIds, the copy's binding and run.ts's follow for a version with a stock origin all use it (a remix follows by slug, as GRA-122 has it), and a run judges by the hosts of the stock version its version recorded (ToolSource.describeVersion), not the catalogue's current one. A named connection the copy would bind to is held to isConnectionUsable as a match is (connection_unusable). A copy made where several match and the slug does not decide holds no default, and its run is refused connection_ambiguous with alternatives. run_tool takes an optional connectionId for any tool (AuthoredRunArgs.connectionId), held to the scope (connection_not_in_scope) and, for a stock copy, to the hosts (connection_hosts_missing); without it the GRA-122 resolution and its refusals stand. @graft/core's listStockToolsForVendor is the console's read of one integration's.

acquire defers to stock, and remixes with from (GRA-243; ADR 0025). similar_tools_exist (tools/meta.ts's similarForGoal) judges the vendor's live toolbox tools and its stock tools the person holds no tool of the name of, a stock hit carrying stock: true; acquire-similar.ts keeps GRA-154's overlap score and adds the index's goal-shaped termCoverage (COVERAGE_THRESHOLD), since a stock description written in full dilutes the overlap. The answer's message carries the three next steps (run it; from to change it; a workflow with ignoreExisting: true): a result, so conduct words are allowed there and not in the description. from: "<vendor>__<name>" names a toolbox or stock tool of the connection's vendor, refused before the build ask when it names nothing; once the approval stands ensureToolForAgent copies a stock tool in, and the job row records from_tool_id (migration 0015). The job (acquire/job.ts's startingPoint) hands the tool's current version's files to the model as ModelJobContext.startingPoint and publishes every draft under that tool's name, so the version lands on the person's row with no stock origin, which is a remix; a tool gone since the job was queued ends it remix_unavailable. The starting point is the authored module alone (forbiddenDraftFiles drops what the install wrote), and the pass is made current only while the tool is still at the version the remix started from (activateToolVersion's expectedCurrentVersionId), else the job ends remix_superseded. Every job's context carries the vendor's stock tools (stockTools: wire name, description, input schema), rendered by @graft/model's renderGoal. packages/mcp/src/acquire-stock.test.ts is the suite, asserting on the scripted model's goal prompt.

An untouched copy follows stock; a remix never does (GRA-242; ADR 0025). The rule is @graft/core's stock/stock-advance.decision.ts, pure and browser-safe: stockLineageOf reads a tool's versions as stock (every one carries a stock origin), remix (some do) or authored (none), and decideStockAdvance advances only stock, only to the catalogue's current version, and only when no version took it. It is applied lazily on reach, never at boot: ensureToolForAgent (a run, a first-class call, the console's run, a promote) through followStock, find_tool over its answered hits, and the tool list over the working set, each through stock-copy.ts's advanceIfBehind and ToolSource.advance, which is @graft/publish's advanceStockCopy: the stock files written to a directory of the advance's own (writePath, as a copy and a publish write) with nothing held, then one transaction that takes the tool's name lock and the tool row's (repo/tool.ts's findAuthoredToolForUpdate), decides again and publishes the next version with its stock origin, the definition and the pointer moving, the binding kept, and the mirror asked once it commits. Two reaches at once record one version, the other's directory left an orphan; a concurrent agent publish takes the same name lock, and one that takes none is caught by the version number's unique constraint. A stock tool's run signal (GRA-244) reads remix off the same stockLineageOf. find_tool marks a toolbox hit stock: true or remixed: true. repo/tool.ts's listToolVersionOrigins (each version's origin with the stock version's number, one statement) feeds both and GET /api/tools's lineage and versions, which the connections screen draws under each tool (apps/web/src/lib/tool-versions.ts: Ready-made v2 or Written by your agent).

An approval is given for a version, and a stock advance carries it unless it widens (GRA-245; ADR 0008 and its amendment of 2026-10-09). approval.tool_version_id (migration 0016, existing rows set to their tool's current version) is the version the answer was given for, and that is the version the person was shown (Greptile on #190): a tool ask's payload names toolVersionId, the version the asking call pinned, and every answer path (the console's route and the card's answer_ask through ask-answer.ts, the elicitation and a taken console answer through approval.ts) passes it to setApproval as asked, never the pointer at answer time. An old ask answered after a republish approves the old version only, so the new one asks; a waiting ask is taken only by a call running its version. setApproval reads the tool under its row lock (findAuthoredToolForUpdate, the advance's), lands a late yes on a stock copy's current version when answerCarriesTo says every later version is a non-widening advance of what the ask showed, and leaves an allow already standing for the current version in place. The gate judges the version the run executes: run.ts passes the pinned version's id to gateToolCall, and decideToolCall takes { toolId, versionId, annotations }; approvalDecision asks again for a write or destructive tool whose allow names another (forThisVersion, read by isForVersion), so any republish, a remix included, asks once; a deny holds across versions. advanceStockCopy moves every agent's answer for the version the copy stood on onto the advance's version in its transaction (repo/approval.ts's carryApprovalsToVersion, on ToolDeps) unless annotationsWiden says the new version went read-only to a write or non-destructive to destructive. The ask names where the tool came from through one browser-safe helper, @graft/core's stock/tool-provenance.rules.ts (toolProvenance(lineage): a badge, a note and the elicitation form's lead), which approval.ts reads for the form's message, the payload's provenance and note, and the ask card's tool.provenance (text, since the card imports nothing); the console's tool-ask-card.tsx calls it on the payload's provenance, absent on an older ask and read as authored.

Every stock tool proves itself in pnpm run test, with no secret (GRA-240; ADR 0025). packages/stock/src/harness.ts answers sentences opening stock tool <vendor>__<name>: for three proofs: proveCheck (the check passes and its annotations are the manifest's), proveTestInput (the schema compiles under the MCP SDK's Ajv and takes test-input.json), and proveReplay: the module run as a dry run by the real runner through the real proxy, over a connection holding only the manifest's hosts, whose vendor is the tool's recording.json. Reads must be the recording's in order (method, host, path, query; a parameter recorded redacted is set aside), writes stop at the preview and never reach the vendor and must be the recording's, and the result must be the recording's. The format, its redaction (redactRecording: by value over the credential's fields, by shape, by field name, by JSON key; a committed recording must be a fixed point) and the live mode are packages/stock/RECORDING.md; the build command (GRA-246) writes through src/recording.ts. GRAFT_STOCK_LIVE=1 pnpm --filter @graft/stock test:live, with GRAFT_STOCK_LIVE_CONNECTIONS for keyed vendors, sends the reads to the vendor and compares status and JSON shape instead (mode.ts); Turbo's strict env mode keeps pnpm run test on replay. The recording is left out of the module's files and the source hash, so re-recording appends no catalogue version. CONTRIBUTING.md says stock is maintainer-built for now.

A stock tool is built by pnpm --filter @graft/stock-build build-tool -- <vendor> "<goal>" [--from <name>] (GRA-246; ADR 0025). packages/stock-build is a leaf nothing imports, as @graft/evals is, since @graft/mcp dev-depends on @graft/stock and the command needs both. loop.ts runs the real acquire over MCP in-process, in @graft/evals' world shape: @graft/mcp's in-memory store, a temporary toolbox, the fake sandbox and the real proxy in front of the maintainer's connection, so it never writes to a person's toolbox or a database. On success build.ts stages the passing draft with its manifest (the connection hosts the proof reached or the module names), its test input and the recording @graft/stock's record.ts makes (one dry run through the proxy, the vendor's echo of the credential mirrored as the proxy redacts it, written only through redactRecording) and, before that, scrubs of every vendor value (GRA-257: src/scrub.ts's scrubRecording over the answers and the test input under a random seed, then a second dry run over the scrubbed answers that the recorded requests and result are taken from, then survivingValuesOf as the last check; no opt-out, and test-input.json is the scrubbed input), formats it with the repository's Biome, proves it with the harness's four proofs as CI will, and only then writes tools/<vendor>/<name>/. --from hands the model the current module through the opening context's hints (past acquire's 4,000 characters) and writes under the current name in its place; without it an existing tool is refused. A failure writes nothing and prints the job's failure and last diagnostics, redacted by the credential's values. The model is GRAFT_MODEL_BACKEND as the server reads it, the connection GRAFT_STOCK_LIVE_CONNECTIONS; packages/stock/README.md is the maintainer's page.

A file moves between tools as a blob, never through the model (GRA-181; ADR 0023). A blob is a directory <id>/ holding data and a meta.json sidecar under the agent's blobs directory, .blobs/<agentId>/ beside the toolboxes on the toolbox volume (the Agent Drive in the hosted form), which the agent's sandbox mounts alone at /blobs as it mounts the toolbox at /tools; the scope is that mount, since a vendored dependency runs in-process and the check never reads it. The runner's ctx.blob.write writes into a temporary directory and renames it once, ctx.blob.read answers a Blob; the ref blob://<id> is a plain string in a tool's result and the next tool's input, and the runner's ledger comes back beside the result as blobs so the server writes one blob row per file through a blob store seam beside the toolbox store. The check bans fs, fs/promises, worker_threads, vm, module, cluster and inspector as defence in depth, the runner refuses a ref that does not resolve under /blobs, and the door refuses blob_not_found, blob_expired and blob_quota before a sandbox is touched, on every path that invokes the runner for an agent: run_tool, every authored tool in the list, execute__<connection> and run_command (GRA-200). 24 hours, 256 MiB per blob, 1 GiB live per agent, all constants; writing one never asks. The working-set sweep's timer runs a second pass that removes expired blobs through the blob store and keeps the row with removed_at. The proxy's cap is GRAFT_PROXY_MAX_BODY_BYTES (default 10 MiB; ADR 0010 as amended 2026-09-22), so a self-host moves a file larger than that only once its operator raises it. The spec is GRA-181 and its sub-issues are the build order.

The write path is GRA-186. ctx is five frozen names, blob the fifth: ctx.blob.write(data, { contentType, name? }) takes a Uint8Array, a Blob or a ReadableStream<Uint8Array>, streams it into /blobs/<id>.tmp/, writes the sidecar (bytes, contentType, name, writtenAt, expiresAt, agentId, toolVersion) and renames the directory once; stat reads the sidecar; read answers a lazy Blob (the paragraph on GRA-187 below). The runner's stdout contract is now an envelope, { result, blobs }, on the sync, detached and dry-run paths alike (readRunnerEnvelope in @graft/runner). Since GRA-193 each server seeds the runner and the skills under /graft/<sha256 of both>/ when that directory is absent and runs from it, handing every command the path as GRAFT_RUNNER, so every sync run goes through this server's runner and run.ts's unwrapEnvelope refuses stdout with no envelope as a module that printed to stdout itself (GRA-199); the one bare result still read is a detached run's result file written by a runner older than the envelope and polled after an upgrade, in sandbox.ts's readRunnerResult. Three per-exec variables ride beside the token and are deleted with it before the module loads: GRAFT_AGENT and GRAFT_TOOL_VERSION for the sidecar, never for a path (GRAFT_AGENT is blob-door.ts's blobAgentEnvironment, on every capability run and inside blobRunEnvironment, since an execute__ command may invoke $GRAFT_RUNNER on a by-hand module), and GRAFT_BLOBS_DIR, the mount path, a variable for the reason GRAFT_RESULT_PATH is one (a backing that maps the sandbox's paths maps the environment's values; the fake does). On the wire a run that wrote nothing answers exactly what it did; one that wrote answers { result, blobs: [{ ref, bytes, contentType, name?, expiresAt }] } in the text block and structuredContent, the list cut at MAX_RESULT_BLOBS (32) with a count and a note; wait_for_process puts the same list beside a detached run's result. The blob table (packages/db/src/schema/blob.ts, migration 0011) is keyed by the id inside the ref and carries the person and the agent, so repo/blob.ts names both in every statement; @graft/core's recordBlobsWritten writes the rows from the ledger, packages/mcp/src/blobs.ts calls it from the sync run (with the version) and the poll (without one, since a poll cannot know it) and fires McpDeps.onBlobWritten, which the server captures as blob_written with the size and the media type and never the name.

The read path and the door are GRA-187. ctx.blob.read(ref) answers fs.openAsBlob over /blobs/<id>/data, typed from the sidecar, so .stream() reads the file in 64 KiB chunks and a FormData upload never holds it whole; a ref that does not resolve there is blob_not_found with the ref in the sentence, whether it is not of the scheme, fails the id rule, names a .tmp, names nothing, or reaches a symlink at the directory or either file (lstat, as the store judges the tree from outside). The runner checks no expiry: the door is the expiry's one judge. packages/mcp/src/blob-door.ts's admitBlobs runs in run.ts after the input is validated and before the approval gate, for a dry run too, so acquire's job learns of a dead ref there: the agent's live bytes (sumLiveBlobBytes: removed_at null and expires_at ahead, one statement) against BLOB_QUOTA_BYTES answers blob_quota with bytes and quota on every run, a ref in the input or not; then every blob:// string leaf of the input, arrays and nested objects included, is looked up under the person and the agent in one statement (findBlobs) and the first without a row is blob_not_found with ref, another agent's row answering the same sentence, and the first past its expiry (@graft/core's isBlobExpired, expiresAt <= now, the sweep's and sumLiveBlobBytes's rule too, GRA-199) or with removed_at set is blob_expired with ref, naming BLOB_TTL_HOURS. Each is a refusal in the run's own shape (isError: true, a refused ledger row) and none asks (ADR 0008). The quota lives beside the scheme, the cap and the TTL in @graft/runner's runner-source.ts, the one file that spells the three numbers; judgeBlobQuota and judgeBlobRefs are pure and blob-door.test.ts pins the sentences, server.test.ts the loop end to end over the fake sandbox. A run the door admits is handed its budget (Greptile on #145): the door's check runs once, before the run, so run.ts puts BLOB_QUOTA_BYTES - liveBytes into the exec as GRAFT_BLOB_BUDGET_BYTES (the quota beside it as GRAFT_BLOB_QUOTA_BYTES, for the sentence), both deleted with the rest before the module loads, and the runner keeps the total it has committed and refuses the write that would pass the budget as blob_quota as the bytes stream in, at the smaller of the per-blob cap and the budget, removing the .tmp directory as blob_too_large does; a refused write is on no ledger. Unset, as under a server older than the variable or a runner run by hand, the per-blob cap alone bounds a write. execute__ commands and run_command pass the same door (GRA-200): tools/execute.ts and tools/authoring.ts call admitBlobs before the exec (the quota alone, since a shell command is not JSON the runner reads), refuse blob_quota in their own shape with no exec, put the two variables into the process's environment beside the runner's path, and hold the grant on the in-flight registry until the process settles, a detached one's on its process name through heldInFlight's budgetBytes; isPolledProcess in sandbox.ts is the one spelling of the runner-answer shape the three readers of a ledger share. Two rules from Greptile's review of #157: the admission and its grant are one step (admitUnderGrant in in-flight.ts, serialised per agent through InFlightRegistry.admit, on run.ts's path too), since two admissions interleaved across the door's await both read the remainder before either reserved it; and the record of a run's blobs is an adoption from the store (blob-budget.ts): a by-hand command can hand the runner any GRAFT_BLOB_BUDGET_BYTES it likes, print any ledger and edit any sidecar, so when the server records a run's ledger (recordBlobsWithinQuota, at the three record sites and at wait_for_process) it takes nothing from the ledger but the ids and builds each row as the sweep builds an orphan's (adoptedBlobOf, parseBlobSidecar): bytes as the store measured, name and media type from the sidecar under the write rules, expiresAt never past the write plus the TTL with the store's last write capped at now, the version the caller's; an entry the store cannot stat, whose sidecar fails the rules or names another agent, is dropped; an entry naming a blob with a row already is listed from its row alone while the row is live and the directory is there (never removed or re-recorded; a second poll of a finished process names the same blob) and dropped otherwise. The quota is then judged over the live rows plus this run's measured bytes; past it the newest are removed through the BlobStore until the rest fit, get no row, and the run is answered a blob_quota failure naming the overshoot (blobQuotaOvershoot, withRecordedBlobs putting the recorded list in place of the declared one). The whole record is one step per agent under InFlightRegistry.exclusive, the critical section admissions take, so two runs finishing together cannot both find room. The record reads nothing off the grant, which expires with a detached hold while the result stays pollable; the quota is the promise. The newest go because the earlier writes are what an honest runner would have committed. Three more rules from Greptile's review of #148: the runner reserves against the budget as each chunk lands, one shared figure across every write in flight, so two writes started together cannot both fit a remainder only one fits; the door subtracts what it has already handed to this agent's runs still in flight (InFlightRegistry.grant and outstandingBudget, in-flight.ts; a detached run's grant rides on its process name until its poll settles it), a per-process record as the in-flight hold is, with ADR 0023's option C as the shape for the day two replicas admit one agent's runs; and a failed run reports the blobs it committed: a module that writes and then throws prints the same ENVELOPE_MARKER line a result's envelope sits behind (__GRAFT_ENVELOPE__:1, @graft/runner) and { result: null, blobs } on stdout before the error (the result file on the detached path), so readRunnerEnvelope is the one reader; run.ts records the rows off it and the failure names the refs, while a timeout prints nothing and its blobs are the sweep's to adopt (GRA-189).

The sweep's blob pass is GRA-189 (ADR 0023, "the sweep deletes"). On the working-set timer, after the working-set pass for an agent and under the same in-flight skip, packages/mcp/src/sweep.ts reads the agent's unremoved rows (listUnremovedBlobs) and what McpDeps.blobStore lists, reads the sidecar and the age (BlobStore.stat, new here: the newest modification time among the directory, data and meta.json, and data's size) of every directory no row claims, and applies what packages/core/src/blob/blob-sweep.decision.ts decides, a pure function in the working-set decision's shape with seven outcomes: keep (a live row, with or without its directory, since a write may still be landing; a .tmp inside the bound), remove (a row past its expiry whose directory is there: the directory goes through the store, then markBlobRemoved sets removed_at, in that order so a throw between the two leaves a mark and never a marked row with bytes on disk), mark (a row past its expiry whose directory is gone), adopt (a directory with a readable sidecar and no row: adoptedBlobOf builds the row from what the store measured and the sidecar clamped to it, since a sidecar is sandbox-written and untrusted: bytes is data's real size, writtenAt the sidecar's unless missing or later than the store's last write, expiresAt never past writtenAt plus the TTL, name and contentType held to BLOB_NAME_RULES and BLOB_CONTENT_TYPE_RULES; adoptBlob writes it, insertAdoptedBlob doing nothing on a conflict, which the applier reads as "a row exists now" and keeps the blob; the next pass judges it as a row), remove_orphan (a directory with no row and no sidecar the sweep can adopt from: absent, unparseable, breaking a write rule or naming another agent; junk, since the rename is the commit and the sidecar precedes it), and remove_tmp (a <blobId>.tmp last written to longer ago than ABANDONED_BLOB_WRITE_SECONDS in bounds.ts, the detached ceiling plus the sync ceiling; RunSweepOptions.abandonedWriteMs is the test seam). Expiry is isBlobExpired's <= (GRA-199). Only BlobStore.readMeta's null, the store's own not-found signal, reads as "no sidecar"; any other read error ends the agent's pass with the error on the report and the blob is judged again next tick. A run that starts after an agent's pass began is caught before every destructive action: the rest of that pass is deferred (SweepBlobCounts.deferred, SweepReport.deferred) and finished next tick. The counts ride on SweepReport.blobs and the server puts them on the sweep's wide event under sweep.blobs; McpDeps.onBlobSwept fires once per remove and remove_orphan and the server captures it as blob_swept with bytes and cause, never the name; a cleared .tmp was never a blob and fires nothing. sweep -- --plan prints the blob actions under blobs.actions beside the demotions.

The blob pass's roster is its own, not the working-set sweep's (GRA-195). The working-set pass walks the live agents (listActiveAgentScopes); the blob pass walks the union of every agent with at least one unremoved blob row (listAgentsWithUnremovedBlobs in packages/db/src/repo/blob.ts, the person off the rows) and every agent the blob store lists a directory for (BlobStore.listAgents(), new here: the agent ids under .blobs/, sorted, a symlink or a name that is not an agent id skipped), with the person of a directory-only agent read off the agent table (listAgentPersonIds in repo/agent.ts, revoked agents included); @graft/core's listBlobSweepAgents is the union, and both reads are deliberately unscoped and pinned by name in repo/scope.test.ts beside listAllActiveAgents, since the sweep has no person to scope by. So a revoked agent's blobs expire, are removed and are marked on the same 24 hour rule as any other's and its bytes stop counting; the in-flight skip is a no-op for it, since it can have no run. An agent the database no longer holds (deleted by hand, its blob rows cascaded away) is walked with no person: the decision is asked with agentExists: false, nothing is adopted, a committed directory is junk once its last write is past the TTL (remove_orphan) and keep (reason unclaimed) until then, a .tmp goes by the bound, and no blob_swept fires, since there is no person to name. SweepBlobCounts.agents counts the agents the pass walked, on the wide event under sweep.blobs; sweep -- --plan shows a revoked agent's actions like any other's. A hosted blob store (GRA-192) implements listAgents beside the five verbs or assertCloudBackings refuses it at boot.

acquire authors both halves, and the playbook carries the one rule (GRA-190). The authoring skill's Moving a file between tools section says when to write a blob and when to return data, how to pipe a response into ctx.blob.write (res.body, the type and name off the headers, base64 in JSON decoded with Buffer.from(data, "base64url") first), how to read one into a FormData, where the ref goes in the result and that a consuming input takes it as a plain string, and the four refusal names; skills.test.ts pins its sentences to the runner's constants. A consuming tool's dry run has a blob to read: job.ts's dryRunInput judges the test input's refs with the door's own functions before the dry run and, for a dead one, or for none where the module reads ctx.blob.read(input.<field>) (the check's contextMembersUsed and blobReadFields, bound by the checker to the default export's two parameters, one level of destructuring followed, so a name in a comment, a helper's own .blob.read, a helper file or a shadowing nested function records nothing), mints a fixture blob through the runner under a budget grant held and released as a run's is (admitUnderGrant in in-flight.ts, GRA-200, and blobRunEnvironment in blob-door.ts, GRA-199; FIXTURE_MODULE beside the probe: a few hundred bytes of text/plain named fixture.txt, the agent's, the normal TTL, a row with no version) and substitutes its ref in the dry run's input alone, saying so in an Attempt N: line; the draft's testInput is never written, and fixtures are never reused across jobs. The door's walkStringLeaves walks an input with a stack, never the call stack, and is the one walker: blobRefsIn reads refs off it, job.ts's substituteBlobRefs replaces through it and @graft/evals's scorers read a result through it (GRA-199); one nested past MAX_INPUT_DEPTH (64) is refused input_invalid naming the bound. The rule on the wire is session.ts's BLOB_RULE, in run_tool's paragraph and word for word in the Hermes skill: a file moves between tools as a blob:// ref, never as content, and the producing tool runs before the consuming one is acquired. The budget was measured first (2,045 of 2,048) and the clause paid for by tightening facts the descriptions carry; SERVER_INSTRUCTIONS's comment lists them. The facts are the descriptions': blobs.ts's BLOB_RESULT_FACT (the list's five fields, the three refusals) on run_tool and appended to every authored tool's definition in tools.ts, and meta.ts's ACQUIRE_BLOB_FACT on acquire, whose hints may carry the ref.

ctx.fetch takes an absolute URL on one of the connection's hosts (GRA-197; ADR 0010 as amended 2026-09-23). The runner (packages/runner/src/runner.mjs, hostRoute) rewrites an absolute https:// URL onto the proxy's host form for its host, ${proxy}/c/<conn>/h/<host><path><query>, the same route ctx.proxyBase(host) names, so the proxy judges the host against the connection's hosts as it does an SDK's call and refuses one the person did not confirm with its existing 403 host_not_in_set; the runner holds no host list and adds no variable. Refused in the runner before any request leaves, each with a sentence: a URL that is not https:, one carrying credentials (user:pass@, the host named and the credentials not), one whose host fails HOST_PATTERN. A relative path resolves as before, redirect: "manual" stays, and a dry run records such a call as the parsed URL's scheme, host and path only, the query dropped and marked ?… and the fragment dropped (recordableTarget), since a vendor-issued URL may carry a signature or a capability in either and the report reaches the authoring model's prompt. The check's fetch-absolute-url still refuses a literal absolute URL at a .fetch( call and its sentence says a URL a vendor hands back at run time may be passed as it is; global-fetch and sdk-not-bound are unchanged. The authoring skill's ctx.fetch bullet says so, and says to prefer ctx.fetch over a vendor SDK for a write flow, since an SDK that retries on a body it does not expect times out against the dry run's 202 preview (GRA-198 decides whether the preview changes shape instead). SERVER_INSTRUCTIONS and the Hermes skill carry no sentence on it: the budget stood at 2,039 of 2,048, and the rule is for the model that writes the module, which is Graft's. This is what let Slack's files.getUploadURLExternal flow (a POST of the bytes to files.slack.com) be authored without an SDK. A declared host known when the module is written is named with host (GRA-213; ADR 0010 as amended 2026-09-24): ctx.fetch("/v1/search", { host: "geocoding-api.open-meteo.com" }) takes a path from that host's root, the runner judges the host by HOST_PATTERN, takes it off the init and sends the call exactly as https://<host><path> would go, so the proxy's host_not_in_set is still the judgement and a dry run records it as scheme, host and path; the check admits a literal in that position (CONTEXT_DECLARATION's init is RequestInit & { host?: string }) and keeps refusing a literal absolute URL. acquire's proof reads carry the same optional host (@graft/model's ProofReadTarget, { path, host: string | null } on the wire, a bare path still read from a script): the probe passes it to ctx.fetch, and job.ts refuses a host outside the connection's set (hostSetOf) before any read, as a failed read naming the declared hosts. The authoring skill says both, and that ctx.proxyBase stays for SDKs. A proof read carries the vendor's own headers, and may be a read POST (GRA-271): ProofReadTarget adds optional headers, method (GET, or POST) and body, on the wire as method null for a GET, headers a list of { name, value } and body null; proofReadProblems refuses a credential-shaped name (Authorization, Cookie, Host, Proxy-*, X-Graft-*), a name that is not a token, a body on a GET and the caps (MAX_PROOF_READ_HEADERS and the two beside it), and job.ts refuses before any read a header the proxy would strip or the scheme sets over the caller's (@graft/proxy's unforwardedRequestHeaderNames, the outgoing policy itself, and schemeReplacedHeaderNames, the scheme's apply over probe values, so a default it supplies only when the caller sends none stays the module's) and a POST its classifyRequest does not call a read, each as a failed read with the sentence. A preview the proxy answers anyway (x-graft-dry-run: intercepted) is a failed read, never a pass. Progress lines name the headers, never a value; the trace records each as name: value so the job's redaction reads it.

The self-hosted image

apps/server/Dockerfile, built from the repository root, is the one image (GRA-33). Its stages: manifests collects every package.json at its path so the deps install layer is a cache hit on any commit that leaves the lockfile alone; build runs the console's vite build and the server's tsdown (apps/server/tsdown.config.ts — the workspace packages inlined, every third-party import left external, and the four files the code resolves off import.meta.url laid beside the bundle: runner.mjs, skills/, drizzle/, the check's worker as a second entry), then the linked backings package's own build when packages/cloud-backings/ is present (GRA-38; the paragraph on GRAFT_BACKINGS above); prod-deps installs the production dependencies of the server and of every inlined package flat under /app/node_modules (node-linker=hoisted, so typescript6 and tar-stream resolve from the server's directory — and the linked package's third-party dependencies land there too, since the lockfile the stage is given names them whenever the package is in the tree); runtime is node:24-slim as user graft, uid 10001 — the sandbox user's uid on purpose, so drafts sandboxes write into the shared toolbox are the server's to remove. node dist/index.mjs is the server; node dist/keys.mjs beside it mints a .env's secrets without pnpm. The linked package, built, is /app/node_modules/@graft/cloud-backings; the open image has no such directory, and the same Dockerfile lines produce both images.

The compose file runs it as service graft on ${GRAFT_PORT:-3000}, joined to two networks: the default one, and sandbox (internal: true, compose-named <project>_sandbox) under the alias proxy, which is what GRAFT_PROXY_PUBLIC_URL=http://proxy:3000/api/proxy hands a sandbox. The Docker socket is mounted (arrangement 1 of packages/sandbox-docker/README.md; the docker:dind sibling is arrangement 2) with group_add: ${GRAFT_DOCKER_GID:-0} for the socket's group. The toolbox is the named volume <project>_toolboxes, mounted at GRAFT_TOOLBOX_ROOT and named again in GRAFT_TOOLBOX_VOLUME so the backing mounts each toolbox into its sandbox as a subpath of the same volume — one tree (packages/toolbox/README.md). Service sandbox has scale: 0: it names the sandbox image at the same reference GRAFT_SANDBOX_IMAGE defaults to, so docker compose pull leaves it on the daemon under the name the server creates sandboxes by, keeps a build: for patching it, and starts nothing. Health checks: pg_isready and GET /api/health; graft waits for Postgres healthy.

CI builds the image on every pull request and asserts that it refuses to start naming what is missing: run with no environment, GRAFT_DATABASE_URL, GRAFT_AUTH_SECRET and GRAFT_HANDOFF_SECRET; run with every field but the keyring secret, GRAFT_KEYRING_SECRET — the cross-field rule GRA-20 made of it, which only runs once every field is present; run with every field under GRAFT_BACKINGS=cloud, the sentence that @graft/cloud-backings is not installed — the open image's proof that it carries no hosted backings (GRA-38), which needs no database because the selector runs before the pool is opened. .github/workflows/release.yml pushes ghcr.io/getmodern-ai/graft and graft-sandbox on a v* tag, for linux/amd64 and linux/arm64: one build leg per image and platform, amd64 on ubuntu-latest and arm64 on GitHub's native ubuntu-24.04-arm, each pushing by digest, and a merge job per image writing the tags over one manifest list (GRA-180). Nothing emulates an architecture; the QEMU cross-build this replaced cost the first tag over two and a half hours on the server image. A workflow_dispatch with an optional ref is how the file is exercised without cutting a release.

v0.1.0, 2026-09-22, is the first tag, and what a release writes is four tags per image — 0.1.0, 0.1, 0 and latest, no v — beside the commit SHA, over one manifest list per image naming both platforms. docker-compose.yml and .env.example pin GRAFT_IMAGE_TAG to that version rather than leaving it on latest (GRA-194): a checkout then runs the release its README was written for, an upgrade is a tag in .env and a pull, and GRAFT_IMAGE_TAG=latest is how a self-hoster opts into following the newest release. A release moves that default in both files. The conformance suite against a running compose project is packages/sandbox-docker/src/compose.test.ts, opt-in by GRAFT_COMPOSE_NETWORK and GRAFT_SANDBOX_IMAGE; its header has the command.

The working-set sweep (ADR 0009) runs inside the server on a plain timer, every GRAFT_SWEEP_INTERVAL_SECONDS (default 300): per agent it demotes what went unused past the idle window, then the least recently used beyond the cap — never a tool used inside the window, and never while the agent has a run in flight — and fires tools/list_changed. Each demotion is a working_set_change row with cause idle or cap, which GET /api/agents/:id/working-set/changes reads for the console. The rule itself is packages/core/src/working-set/sweep.decision.ts, a pure function; packages/mcp/src/sweep.ts applies it, and on the same tick runs the blob pass over its own roster, revoked agents included (the paragraphs The sweep's blob pass is GRA-189 and The blob pass's roster is its own above). pnpm --filter @graft/server sweep -- --plan prints what a sweep would do without doing it; without --plan it demotes and removes, from a process that can neither see a running server's in-flight runs nor notify its sessions, so use that form with the server stopped.

The console

apps/web (@graft/web, GRA-26) is the console (CONTEXT.md, Console; ADR 0006): React on Vite, TanStack Router with file routes and Query, Tailwind 4, and Cando's primitives copied into src/components/ui over Base UI (GRA-45). There is deliberately no packages/ui: one SPA does not warrant a second workspace. The primitives are Cando's files, not the registry's. Each is packages/ui/src/components/<name>.tsx at Cando's origin/main with four import rewrites — @cando/ui/lib/utils → @/lib/utils, @cando/ui/components/icons → @/components/icons, @cando/ui/hooks/use-mobile → @/hooks/use-mobile, @cando/ui/components/<x> → @/components/ui/<x> — a comment that names a Cando app file qualified as Cando's, and where a file has to deviate, a comment on the spot naming why (today only kbd.tsx's KbdGroup props). Do not run npx shadcn add over them: the registry's base-lyra output is what they replaced, and it would put rounded-none and text-xs back. To re-sync one, git show origin/main:packages/ui/src/components/<name>.tsx in the Cando checkout, apply the same rewrites, and read the diff; a local deviation carries a comment naming why. Not every Cando primitive is here — the product-specific ones (bloom faces, dot sprite, bubble, message, attachment, chart, calendar, carousel, combobox, command, inline-edit) are not ported, and the rest arrive as a screen needs them; src/hooks/use-mobile.ts came with sidebar.tsx, and its colocated test pins the breakpoint against index.css. Biome excludes the generated src/routeTree.gen.ts and switches a few rules off for src/components/ui/** — the primitives' own shape trips them, and Cando does the same for its packages/ui.

The console is drawn with Cando's design system (ADR 0017, GRA-44). src/index.css is Cando's globals.css with the product-specific pieces left out — its header says which and why — and the values between the cando:tokens:start/end sentinels are copied whole from Cando's managed block, never edited here; Figma stays upstream in Cando. There is no design file for the console, so a screen is composed from the system's patterns and its pull request carries a delta list saying what was composed from what and where it departs. Icons are Material Symbols generated into src/components/icons.tsx by scripts/generate-icons.mjs — add a glyph to NAMES there and run pnpm --filter @graft/web generate-icons then pnpm run check; there is no lucide-react. The theme is next-themes through src/components/theme-provider.tsx, mounted in routes/__root.tsx with Cando's four settings (class attribute, system default, vite-ui-theme storage key); src/components/ui/sonner.tsx is Cando's theme-aware Toaster, the first primitive copied (under GRA-44, because sonner does not read the .dark class). The two theme-color hexes — in index.html's metas and in theme-provider.tsx — are the only colours written as literals on purpose, and both are entries in COLOUR_EXCEPTIONS. Two guards keep dark mode correct and CI runs both as their own steps: pnpm run check-colours (a hard-coded colour in a component or in index.html, a colour literal inside @theme, a base token left behind by its family — logic in src/tokens/*.ts, exceptions in COLOUR_EXCEPTIONS there and never inline) and pnpm run check-tokens (every design-token utility compiles into dist/assets; it needs a build, which check-types runs). GT Standard L and GT Standard Mono VF ship with the console: the six .woff2 files under src/fonts/ and Cando's @font-face block in index.css, byte-identical to Cando's, under the Grilli Type licence Aleks confirmed covers Graft (GRA-49).

What the console composes for itself from the primitives (GRA-45). src/components/code-block.tsx is the one place a token, a shell line or an ask's raw payload is shown: a label row with CopyButton, the text on the muted band in the mono stack, an optional hint — Cando has no code block, so this is composed from its tokens rather than copied. A destructive confirmation is an AlertDialog in Cando's shape, and components/agent/revoke-agent-dialog.tsx is the reference: a question for the title, the blast radius in the description, AlertDialogCancel first in the DOM, the destructive AlertDialogAction last with a present-participle label while pending, both disabled while it is, and close requests ignored until the mutation settles. A choice among fixed options is the Select primitive with items on the root, so the closed trigger shows the option's label — never a raw <select>. Every status chip comes from src/lib/status-chips.ts (GRA-47): one map per vocabulary — agent, connection, call outcome, approval, working-set change, model key, tool annotation — to { variant, label }, drawn through components/status-chip.tsx, so a call site never picks a tone or spells a label. The rule the map encodes: success for a state in which the thing works (Connected, Allowed, an OK call, an Active agent, a key that is Set), destructive for one that will not until someone acts (Revoked, Denied, Error, Needs re-consent), outline for waiting on something (a credential, a consent, a reconnection), and secondary for the neutral rest; ToolAnnotations reads its three from the same file. Labels are sentence case, like every label in the console, and its colocated test pins both.

Agent actions (GRA-132, GRA-135): the agents table's dropdown opens the name/cap/idle-window edit dialog. The table groups Active and Revoked rows, omitting empty sections. An empty agents table keeps its headers and a centered, muted full-width sentence, following Cando's connections table. Archiving is excluded from this branch; the detail page retains standalone revocation (ADR 0007, ADR 0018).

Agent harnesses (GRA-135, GRA-168): the Harnesses column names connectedVia.clientName, recorded at OAuth consent (ADR 0018). A static-token agent records no harness, so its cell is the prompt Set up harness while it is active and Not recorded once revoked — a prompt, never a claim about whether the harness is connected, since a Hermes that is running tools looks the same as one never configured. The prompt, the row's actions menu and the agent page's header button all open Connect a harness (agent-connection-dialog.tsx), named for what the person does: the word connection is a vendor account (CONTEXT.md) and the screen beside this one, and the dialog never says it. The dialog shares creation's setup (harness-setup.tsx: the URL, a harness picker, the token's place and the configuration in that harness's shape — GRA-152) but cannot retrieve the token; the token line carries a saved-token placeholder (ADR 0007). OAuth agents get the URL and consent instructions. An agent awaiting its harness (Setup's, no token and no client) gets both: the URL, whose consent page chooses it, and Issue a token (POST /api/agents/:id/token, GRA-208), which shows the token once as creation does. Agent names are links to the standalone /agents/:agentId page, and every row's menu has View agent, revoked rows included; the detail drawer is deferred. That page keeps scope and limit editors, the working set, approvals, history and standalone revocation; revoked records are read-only. GET /api/agents includes workingSetCount, counted against each person-scoped agent in the same statement; the table shows count/cap with Cando's status dot. Since GRA-290 the same one statement also answers each agent's connectionIds (the scope as it resolves under its mode, listScopeConnectionIds's rule) and lastActiveAt (its last ledger row that was not a dry run, any outcome), and the answer is AgentListAnswer, { agents, hasRunTool }: the flag is a per-agent exists over ledger rows that are not dry runs and answered ok, in the same statement, folded across the person's agents in @graft/core's listAgents. A lost static token is rotated, not a new agent (GRA-306; ADR 0007 as amended 2026-10-14): the page's Rotate token (rotate-token-dialog.tsx, drawn when lib/agent-token.ts's tokenRotatable says so) confirms, then calls POST /api/agents/:id/token/rotate, @graft/core's rotateAgentToken, which writes the new hash over the one it read through issueAgentToken's replacing and answers the token once; an agent a client holds, one awaiting its first token and a revoked one are 409 agent_token_not_rotatable. The door reads the token per request, so the old one is refused at once, on its open session too (mcp.test.ts), and the new one continues there.

Setup is one record per person and three routes (GRA-204; ADR 0024). The setup table (packages/db/src/schema/setup.ts, migration 0012) keys on the person: the step reached, the harness, the agent, the open connection ask, the connection, the acquire job, the tool, and started_at, completed_at, skipped_at; repo/setup.ts names the person in every statement, pinned in repo/setup.test.ts, and lockSetup makes the row and locks it so two starts, or a start and a skip, serialise. @graft/core/setup/ holds the service (getSetupState, startSetup, skipSetup), the browser-safe rules (shouldShowSetup over the record and the person's connection and tool counts, isAwaitingHarness over an agent, currentSetupStep) and the harness data (SETUP_HARNESSES: the seven in the marketing site's order, each with its kind, oauth or token, the site's label and the finish step's connection steps; since GRA-208 it is also setup-prompt.ts's list, SETUP_PROMPT_HARNESSES, which GET /api/setup-prompt answers as { id, label, description }). GET /api/setup answers SetupState (the record, step, show, the agent it runs as, the active agents); POST /api/setup/start (SetupStartBody) mints an agent with no token and no client through createAgentAwaitingHarness when the person has none, adopts the one when there is one, and needs agentId among several; POST /api/setup/skip sets skipped_at. Both are rows in analytics-routes.ts carrying harness, read off the answer by the row's properties. The console: routes/_auth/setup.tsx is under the guard and outside the shell; _shell/route.tsx's beforeLoad redirects to it on show, except /consent and /pending (lib/setup-intercept.ts); one component per step under components/setup/, wired in setup-step.tsx's map, each moving the record through useSetupMutation, which writes the answered state into the one ["setup"] entry. The agents table draws Awaiting harness (AGENT_STATUS_CHIP.awaiting_harness, outline). A person who skipped meets Home's Get started checklist instead (GRA-328, lib/home-checklist.ts): three steps (an agent whose software is connected, any connection, hasRunTool), in place of the panels with none done and above them with some, offering Resume Setup while the skip stands.

The integration and connect steps are the agent's own connection ask (GRA-206). The starters are @graft/core/setup/starter-vendors.ts, browser-safe, one entry each (vendor slug, hosts, docs, the keyring's scheme and parameters, the curated read-only goal, runInput with its default, the outcome sentence); adding one is one entry. Starters are one-click only (GRA-216): the list is common services a link provider connects by OAuth (Gmail, Google Calendar, Google Drive, Google Sheets, Slack, Notion, GitHub, HubSpot, and Stripe, a stock integration since GRA-261) plus Open-Meteo, the keyless one, each task a GET, since the check counts a POST as a write and the result step runs only a read-only tool (so no Linear, whose API is GraphQL). Every task needs nothing the person has to look up (GRA-217): no id, no name, no link; Open-Meteo's city, with its default, is the one input, and every other starter's hints names its one endpoint and says the tool takes no input (starter-vendors.test.ts pins both). That is why Google Sheets' task lists spreadsheets through Drive's search (both hosts on the entry, GRA-259) rather than reading rows, which need a spreadsheet's id and a range; a starter's vendor slug is the catalogue's app with hyphens read as underscores (google-drive is Pipedream's google_drive). The keyring's scheme on each entry stays truthful for the form path a link provider steps aside to (GRA-147). setupVendorOptions is the pure filter and order over each starter's covering provider: only link, none and keyless (a none scheme the form provider lists) are offered, in that order, and a starter the keyring would connect with a pasted key or an operator's own OAuth client is dropped, so the keyring alone offers Open-Meteo alone; SetupConnectKind has no form. GET /api/setup/vendors asks providerFor per starter in Backings.providers order (apps/server/src/setup-connect.ts); the console never computes coverage. POST /api/setup/connect (SetupConnectBody: { starterId } or { connectionId }) calls GRA-203's routeConnectionProposal as the setup's agent, through ApiOptions.connectionRouting (the server binds its McpDeps), and moves the record through moveSetupConnect: an ask to connect with pendingActionId (a repeat re-uses it by proposal key), a connection made or found at once, or Another integration's ordinary-form connection (added to the agent's scope), to goal. GET /api/setup reads the ask the record waits on and never takes a live answer: answered with a connection (or a scope ask allowed) that is still live, usable and in the agent's scope it moves to goal naming it; declined, expired, gone, or answered with a connection revoked or taken out of the scope since (that answer is taken, so the routing stops handing it back, under the record's lock so two reads take it once), back to vendor, and on goal a connection that stopped standing takes it back too, judged again under the lock (moveSetupConnect's confirm) so a restore meanwhile stands. The connect route that finds such an answer (or finds a poll reopened the record for it) routes once more, and that second routing's move lands only on the record as it was seen on vendor, its updatedAt unchanged (fromVendorAt; saveSetup moves it forward by at least a millisecond on every write), so the choice in flight wins over a poll and any choice another tab made since stands, even one that closed and left the record on vendor again. A stale answer is judged again under the lock before it is taken, so one made good meanwhile is left for the next read. setup_step_completed carries step: vendor from the connect route's row, connect captured by the request whose move changed the record (SetupMoveResult.moved), so two reads of one answer count once. A listed agent's scope grown by Setup (Another integration, a scope ask answered in the console) is announced to its session, since no waiting call of its own does. The console's connect step draws the open ask from the inbox's list with PendingActionCard under origin="setup" (no model provenance, the proposal editor folded behind Edit the connection; the inbox and the handoff page pass nothing and draw the cards as before), and polls both reads every 3 s so an answer given in the inbox or a chat card moves it too. The goal step's starter is starterVendorFor(connection.vendor).

Build is the build approval, and the building step reads the job (GRA-207; ADR 0024, apps/server/src/setup-build.ts). GET /api/setup/goal answers SetupGoalContext: the record's connection, starterId, the curated goal (empty for another vendor) and build, which is { available: false, reason: "acquire_unconfigured", message } whenever @graft/mcp's acquireConfigured (the acquire door's own model check) says no; the console then shows the message naming the variables and disables Build. POST /api/setup/build (SetupBuildBody: { goal }) is @graft/core's startSetupBuild, one transaction under lockSetup: from goal only, the connection not revoked (refused connection_revoked; a revoked row stays in a resolved scope) and still in the agent's scope, grantBuildApproval for the pair (the standing row answered when the connection card already granted it), createAcquireJob with Setup's own first line and setupBuildHints as hints (the starter's documentation, after the starter's own hints when the goal is its curated one unchanged; GRA-209), the record to building naming the job; the route then kicks the runner. No pending action is opened, and similar_tools_exist is not asked. ApiOptions.acquire is the model, the job's deps and the runner (the server binds its McpDeps). GET /api/agents/:id/acquire-jobs/:jobId answers acquire_status's shape (acquireStatusOf) for one of the person's agents, never held; another person's agent or job is a 404. GET /api/setup learns a succeeded job's tool through moveSetupBuild's built: building to result with toolId, or the tool noted on finish, or on completed when Finish Setup came first. A failed job leaves the record on building; POST /api/setup/goal (Change the goal) goes back to goal with the job cleared, refused while the job may still pass, and POST /api/setup/continue (Continue while it runs) goes to finish with the job kept. setup_step_completed adds goal (the build route's row) and building (captured by the read whose move named the tool, SetupMoveResult.moved). The console's building step polls the job route every 2 s and draws each line beside its stage's sentence on the stage's first line (lib/setup-progress.ts's progressStage and explainProgress, keyed on acquire/job.ts's lines; a new progress line there wants a rule and a case in setup-progress.test.ts).

The goal step's chips come from the triage model, and the curated goal is in the person's voice (GRA-209). A starter's goal is what the person reads as their own, short and in the first person; the detail the model needs (the input's field, the endpoints, Read only) is the starter's hints, which reaches the job only beside the curated goal unchanged. ModelAdapter carries an optional proposeGoals (@graft/model's propose-goals.ts, shaped as triage.ts's calls: the triage model, a strict output, one attempt, traced with situation: "propose_goals" and the request's traceId, setup:<personId>, where a job's id would be; bounded at GOAL_PROPOSAL_TIMEOUT_MS, 8 s, and never throwing). A chip must end in a tool that runs with nothing to look up (GRA-217): the request carries the connection's hosts, the model answers each proposal as { task, host, inputs: [{ name, default }] }, and goal-grounding.ts's groundedGoals keeps one only when its host is one of the connection's and every input has a default, the dropped count riding on the wide event as goalSuggestions.dropped; the system prompt states the rules. The provider's adapter implements it, the scripted one answers scriptedGoals(displayName) through the same goalProposalOf (each on the connection's first host, no input), and the router sends it where the person's jobs go, so a person's own key carries their vendor's name to their provider alone. GET /api/setup/goal/suggestions answers SetupGoalSuggestions, { suggestions }, up to three or none: none and no call where Build is unavailable, the record is not on an open goal step (skipped, completed or elsewhere) or its connection is gone or revoked, none where the proposal declined, timed out, failed or answered nothing usable; the outcome rides on the wide event under goalSuggestions, never the goals. The route is a read, outside the api rate-limit bucket, so the model is asked once per person and connection inside a window, and the answer held in flight and after (createGoalSuggestionMemo in setup-build.ts, one per process; cached: true on the wide event): an hour for a proposal, two minutes from when it settled for any other outcome or a throw, so a timeout does not hide the chips for the hour. It is its own route so the goal step draws at once; components/setup/goal-suggestions.tsx asks it after, keyed by the connection outside ["setup"], and draws outline Button chips that fill the field, or nothing.

An integration's page builds a tool, as an agent the console picks by rule (GRA-303, GRA-346; ADR 0024 as amended 2026-10-14 and 2026-10-15; apps/server/src/connection-acquire.ts). POST /api/connections/:id/acquire (ConnectionAcquireBody: { agentId, goal }) is @graft/core's startConsoleAcquire, one transaction with no Setup record: the connection read locked (findConnectionForNoKeyUpdate, so a revoke's deletion of build approvals cannot interleave and a scope save's foreign-key check is not blocked), then the agent (findAgentForUpdate, the order a scope answer takes them; revoked is agent_revoked), the connection the person's, not revoked, usable and in the agent's scope (connection_revoked, connection_unusable, connection_not_in_scope), grantBuildApproval and createAcquireJob with Setup's first line and setupBuildHints; then the runner is kicked. It answers ConnectionAcquireJob (acquire_status's shape plus connectionId, agent, goal, createdAt), 201, and is refused acquire_unconfigured with Setup's sentence. Like Build it skips similar_tools_exist: the person asked for a new tool on a page that lists the integration's tools. GET /api/connections/:id/acquire-jobs (ConnectionAcquireJobs) is the last day's jobs, at most twenty, newest first, across the person's agents (repo/acquire-job.ts's listAcquireJobsForConnection, person-scoped through the agent join); each is polled after through the agent's job route. GET /api/connections/:id/goal-suggestions (ConnectionGoalSuggestions) is Setup's proposal keyed by the connection alone (proposeGoalsForConnection, the same memo) with build beside it. Another person's agent or connection is a 404 on all three; Create counts as acquire_started with job_id and agent_id. The console's dialog (components/integration/new-tool-dialog.tsx, GRA-320, GRA-346) asks only for the goal: the build runs as lib/new-tool.ts's buildingAgentId, the agent that last used the integration (the access read's lastUsedAt) or else the first whose scope reaches a connected connection, named beside Create since Create records its build approval; with nobody, Create is disabled. The ready view's Try it in picks the agent to copy a prompt for afterwards (tryInAgents, the build's agent first), because any agent with the integration in scope finds the tool through find_tool.

A connection can be proposed from an API's docs, for the console's Flow C (GRA-299). The person names an integration and pastes a link to its API docs; POST /api/connection-proposals (ConnectionProposalBody: { name, docsUrl }) starts a short job and answers { id }, GET /api/connection-proposals/:id answers ConnectionProposalState (status running, ready or failed; stage one of @graft/mcp's DOCS_PROPOSAL_STAGES, the checklist's four lines; found filling in; proposal in request_connection's shape once ready; failure with a reason word once failed), and GET /api/connection-proposals answers ConnectionProposalAvailability, unavailable with the variables to set wherever acquireConfigured says no. The work is proposeConnectionFromDocs (packages/mcp/src/proposal-from-docs.ts): the pasted page alone through readWebPage, bounded in windows and characters, then ModelAdapter.proposeConnection (@graft/model's propose-connection.ts: the triage model, strict output, one attempt, a bound, traced as propose_connection under connect:<personId>, routed to the person's own key; the scripted backing answers a script's connectionProposal or a guess from the docs' host), then normaliseProposal, so a private host is host_not_public as it is for an agent's proposal, then each host resolved once (a private answer refused, a name that does not resolve listed in unresolvedHosts). The jobs live in the process (apps/server/src/connection-proposals.ts) for 15 minutes, one in flight per person (proposal_running); a restart loses one, and another person's id is a 404. The settled job is logged as counts and kinds, never the name, the link, the pages or the proposal.

The result step runs the tool, and the finish step connects the harness (GRA-208; ADR 0024, apps/server/src/setup-finish.ts and tool-run.ts). GET /api/setup/tool answers SetupToolContext: the record's agent and harness, the connection, the job's goal and status (with the failure's sentence), the tool once it landed (wire name, input schema, readOnly) and the starter's runInput. POST /api/agents/:id/tools/:vendor/:name/run (ToolRunBody: { input? }) is the console's second caller of a run: @graft/mcp's runAuthoredTool with the server's McpDeps (ApiOptions.run), synchronous at DEFAULT_COMMAND_TIMEOUT_SECONDS, NO_ELICITATION, never a dry run, answering AgentToolRunOutput ({ ok: true, result } or { ok: false, reason, message, answer }, the run's own refusal or failure); a tool whose annotation is not read-only is 409 tool_not_read_only and one outside the agent's working set 409 tool_not_in_working_set, both before the run and again on the run's own read of the tool inside the agent's in-flight hold (AuthoredRunArgs.admit), so a republish or a demotion in between cannot reach the gate or the sandbox and no ask is ever opened from the console; another person's agent is a 404. POST /api/setup/result is moveSetupBuild's finish (result to finish), and POST /api/setup/finish is @graft/core's finishSetup: from finish only, one transaction under lockSetup, the record to completed and, for a token harness whose agent is still awaiting it, the token issued through issueAwaitingAgentToken and answered once beside the state (SetupFinishOutput); a second finish is 409 setup_completed. POST /api/agents/:id/token is the same issue for Connect a harness and the finish step's Issue the token; the write (issueAgentToken in repo/agent.ts) holds the awaiting rule in its statement, so two issues mint one token. The route is @graft/core's issueConsoleAgentToken, which also replaces the token of the agent Setup runs as while the record is not completed and no client holds the agent (ADR 0024 as amended 2026-09-25; Greptile on #172): the page held the only plaintext, so a reload lost it. The replacement is judged again under lockSetup and written with issueAgentToken's replacing, the hash it read, so the old token stops resolving and nothing lands after the finish; the finish step offers it as Issue a new token on the saved-token block (finishSections' reissue, tokenReplaceable). Any other agent with a token is still 409 agent_not_awaiting_harness. setup_step_completed adds result, and setup_completed carries the harness. The console: result-step.tsx draws the input from the tool's own inputSchema (GRA-217, lib/setup-run-input.ts's runInputView): a field per string, number, integer, boolean, enum or list of scalars, starting at the starter's runInput only where the field names match, else the schema's default or first examples entry; a required field with no value says This tool needs … and Run waits (canRun), the required fields drawn first since a stored schema's keys come back in jsonb's order; JSON only for a schema too complex to draw (a nested object, a list of objects, a union, a composed root), where a required key still at the skeleton's empty object or list counts as missing. It runs a read-only tool once on arrival when the form as drawn needs nothing from the person: no input, or every required field at a starting value and every value in its type (lib/setup-result.ts's runsOnArrival, so the ready-made Gmail search and Open-Meteo's prefilled city run at once; a required field nothing fills, or a JSON input, waits for Run) and shows the answer in CodeBlock; a refusal or a failure is one sentence with the raw text, cut to 500 characters, behind Details (lib/short-failure.ts's shortFailure and runFailure: an HTML body reads The integration answered with a web page instead of data (status N)), and the building step's and the finish step's failure reasons go through the same shortFailure; finish-step.tsx draws, by lib/setup-finish.ts's finishVariant, the token once with the configuration blocks, the URL and the harness's steps ending on the consent page, or (a record that adopted an agent, harness null) the first request to ask in the chat, with SetupPromptBlock personalised with the agent, the connection and the tool (arriving while the job runs, the state read every 3 s). The finish's answer, token included, is held in routes/_auth/setup.tsx's state, never the query cache, so the step keeps showing it once the record reads completed. The consent card pre-selects the person's one agent awaiting its harness and falls back to A new agent with none or several (lib/consent-default.ts).

The rail is navigable, every step has one footer, and Finish Setup leaves (GRA-215). POST /api/setup/back (SetupBackBody: { step }) is @graft/core's moveSetupBack: under lockSetup, only to a step setupBackTargets names (every step before the record's that it holds what for, setupStepReachable: connect needs an ask or a connection, the goal a connection, the building step a job, the result a tool), refused setup_step_ahead, setup_step_unavailable (the result a record passed by Continue while it runs) or setup_completed. Looking back discards nothing: the connection, the job and the tool stay, a running job keeps running and is learned again on the building step. POST /api/setup/next (SetupNextBody) is moveSetupOn, Continue on a step returned to with nothing changed: harness to vendor (a different harness only while Setup's own agent awaits it, renaming it where its name was the old harness's default, else harness_fixed), connect to goal with the connection made, goal to building with the held job, building to result with the tool; from must be the record's step (setup_step). What leaves a held job behind is a forward action that says so: the connect moves drop the job and tool when a different connection replaces the one they were acquired against (an ask leaves them until it is answered; a reopen or a lost connection clears them), and while the held job is queued or running a choice of another vendor (POST /api/setup/connect, judged on the vendor, then again on the row the same starter's routing resolves or the ask it hands back settles on, GRA-216's review) or a Build (startSetupBuild) is refused job_running unless the body carries discardJob: true, which the console sends after DiscardJobDialog. SetupGoalContext.job is the held job, whose goal the goal step shows and offers Continue to while the text is unchanged. Neither route is a mutation-table row. The console: SetupRail and SetupProgress link the steps setupRail(step, record) marks (lib/setup-steps.ts, with backTargetOf for the footer), labelled Integration and Task; SetupFooter (Back bottom left through useSetupBack, the step's primary bottom right) closes every step, the connect step's open ask keeping its card's own Connect as the primary; the building step is progressCard (lib/setup-progress.ts: a plain label per stage, the newest line, the attempt past the first, the lines behind Details); the finish is finishSections (lib/setup-finish.ts): the prompt with the URL and steps behind Set it up by hand for an OAuth harness, the token block issued by the footer's Issue the token (POST /api/agents/:id/token) before Finish Setup plus the prompt for a token harness, the one request for an adopted agent. One press of Finish Setup completes and, unless the card opened the page, lands on /agents/$agentId with setupFinishedToast (lib/setup-page.ts's afterSetupFinish: leave, close, or stay only for a token the finish itself issued); GRA-208 stayed on every console visit and needed a second press on Open the console.

Setup v2 picks the task before the connection, and builds the moment it lands (the Figma "Console / Setup v2" frames and their 2026-09-29 decisions; the funnel since 2026-09-25 lost every person who connected at the goal step). The record keeps starter_id and goal (migration 0013): POST /api/setup/starter (SetupStarterBody, null to go back) and POST /api/setup/task (SetupTaskBody) save them through @graft/core's planSetup while the record stands on vendor, so the step order and GRA-215's back rules are unchanged; the task route connects the starter as the connect route does, and setup-build.ts's buildPlannedSetup starts the job whenever the record reaches goal with a connection and a saved task, there and on the GET /api/setup that learns the connection. Each starter offers moreTasks beside its curated goal (starterTasks), each with its own hints, held to GRA-217's rules and aligned with ADR 0025's stock reads, which replace them as stock lands. The console draws four stages over the seven steps (lib/setup-stages.ts: the vendor step is Integration until a starter is chosen and Tool after), a stepper in the top bar, a centred hero (SetupEyebrowContext), selection cards with marks (setup-logo.tsx; the Figma file's app logos and the marketing site's harness marks under src/assets/setup/) and a sticky footer with a summary; tool-step.tsx and task-picker.tsx are the tool screen. The rows count setup_step_completed as vendor (or vendor_cleared) and goal.

Setup offers the integration's ready-made tools before a task to build (GRA-276; ADR 0024 as amended 2026-10-11; ADR 0025). Each starter names a few of its stock tools in readyMade ({ tool, goal, replaces? }, the curated task's best match first, the goal in the person's voice, replaces the starter's tasks it does in other words), held by @graft/stock's workspace test to tools whose manifest is read-only, whose required inputs all have a default or are the starter's runInput, and none of whose inputs starts at an example the result step would send unchanged. @graft/core's browser-safe setup/ready-made.ts (setupReadyMade) keeps the ones the catalogue holds as read-only, at most SETUP_READY_MADE_MAX, and GET /api/setup/stock?starterId= answers them (SetupReadyMade, from listStockToolsForVendor over ApiDeps.stock, defaultStockDeps when absent). POST /api/setup/stock (SetupStockBody: { tool, discardJob? }, apps/server/src/setup-stock.ts) judges the choice against that same offer for the record's starter or its connection's (readyMadeChoice, else 404 stock_tool_not_offered), saves it through planSetup's stockTool beside its goal, only onto the record it judged (expected, else setup_step; Greptile on #218) (the record's stock_tool column, migration 0018; a task without it clears it, as another starter and a Build do), connects the starter as the task route does, and on the task step takes it at once: @graft/core's takeSetupStockTool, one transaction under lockSetup, refuses as Build refuses (setup_step, connection_revoked, connection_not_in_scope, job_running) plus stock_tool_unplanned, stock_tool_other_integration, the copy's own reasons, tool_not_read_only and tool_has_no_version, copies through @graft/mcp's ensureToolForAgent bound to the record's connection, promotes with promoteTool, and moves the record to result with toolId and no job. buildPlannedSetup takes a planned ready-made tool instead of building when the connection lands. The result step reads it as a built tool (setupToolContext's goal is the record's for a ready-made tool, completeSetupResult and the result's back target need a tool, not a job), and it needs no model. The result step runs it over the record's connection (ToolRunBody.connectionId, held to the scope and a stock copy's hosts), so a tool the person already held bound to another account runs over the one Setup connected. The row counts setup_step_completed as goal with readyMade: true. The console's tool screen and task step draw ready-made-picker.tsx above the tasks, which become Or have Graft build one less any task a ready-made tool does; one choice across both (lib/setup-ready-made.ts's ToolChoice), Use this tool beside Build this tool.

The integration step searches a directory, a seam with two backings (ADR 0001 as amended 2026-10-10). @graft/core's IntegrationDirectory (home, search, get; connection/directory.ts) is Backings.directory: the private package's, over its link provider's catalogue, or null, when the server answers the starters it connects (apps/server/src/directory.ts's createStarterDirectory). GET /api/setup/directory (SetupDirectoryHome: the count, the categories, seven popular, the logo wall) and GET /api/setup/directory/search (SetupDirectoryPage, by q, category and an opaque cursor) answer it, each entry with the starterId it is when it is one. A starter takes the starter's path; any other entry is held by the page (SetupAppContext, so the stepper and browser Back read it as the Tool stage) until its task is chosen, when POST /api/setup/task with its slug reads the entry from the directory again and proposes it (directoryProposal: the slug as the vendor, the first host as the primary) through the starter's routing (connectSetupProposal). No column holds it: a reload on that tool screen returns to the directory.

The person connects an integration with no agent asking (GRA-304; ADR 0006 and ADR 0008 as amended 2026-10-14; apps/server/src/console-connect.ts). POST /api/connect (ConnectBody: { slug }, read again from the directory, a starter's as the starter, or { proposal } in request_connection's shape, and access, full or ask) normalises the proposal, routes it by providerFor and answers ConsoleConnectResult, writing no pending action: connected (a keyless keyring row made at once, or the person's covering one answered or widened; a no-step provider's row), form (the keyring's, nothing written; the console posts POST /api/connections with the same access; a link provider that cannot start answers it too with fallback), or link with a connectId. A link's state is the short-lived record itself, @graft/core's signConsoleLinkState under its own HMAC domain, so provider-link.ts's return route tells it from an ask's, completes through completeConsoleLink and lands on /link/callback with connectId and no pendingActionId. No scope row is written: the row reaches every all agent by being the person's. full is recordFullAccess: each active agent whose scope reaches the row at that moment gets grantBuildApproval and allowVendorWhenConnecting. Counted as connection_started (kind, access, created).

The console's Add a custom API is Flow C over those two (GRA-323; apps/web/src/components/integration/add-custom-api-dialog.tsx, exported as AddCustomApiDialog with an optional initialName and initialDocsUrl, opened from the Integrations header). The name step requires the docs link; the job is polled every second and drawn as the four lines (lib/custom-api.ts's proposalChecklist); proposal_running follows the person's own job; a host_not_public or input_invalid failure, and a deployment with no model, open the same check step as the form (draftFromFailure, draftByHand). Connect posts POST /api/connect with the checked proposal and the access, then on form POST /api/connections with the key and the same access, runs the consent popup for an OAuth client, or on link opens the provider's page and polls GET /api/connections/:connectId; a connection lands on /integrations/$vendor with a toast. The fields are ConnectionFormFields and CredentialFields, the agent's proposal's, so the key is entered here and nowhere else.

The directory answers outside Setup too, beside the person's own suggestions (ADR 0001 as amended 2026-10-14; GRA-305). GET /api/integrations/directory and /search are Setup's two handlers mounted a second time, same answers. GET /api/integrations/suggested (IntegrationSuggestions) is @graft/core's listSuggestedIntegrations over repo/pending-action.ts's listSuggestedConnectionAsks: per vendor, the newest connection ask of the person's live agents, kept when it was not answered with a connection and the person holds no unrevoked connection of that vendor, newest first, at most SUGGESTED_INTEGRATIONS_LIMIT (12), with the agent, the ask's state (open, declined, expired) and age, and the directory entry's logoUrl (null under the starters). directory and suggested are registered before any /integrations/:vendor… route (INTEGRATION_RESERVED_SEGMENTS).

Setup's words are integration and task (GRA-216; CONTEXT.md, Integration; ADR 0024's amendment of 2026-09-24). Person-facing copy says integration for the service a person connects and task for what the first tool should do: Setup's steps (Choose an integration, What should your first tool do?, the Task field, Suggested tasks, Another integration), the console's connection screens and pending cards, the ask card's rendered text, and the server's sentences a person reads (Setup's refusals, the OAuth callback's). Code identifiers (vendor, STARTER_VENDORS, the <vendor>__<name> wire form, columns, the record's step values vendor and goal, the analytics step), and model-facing text (SERVER_INSTRUCTIONS, tool descriptions, the authoring skill, handoff-message.ts, the acquire job's own progress lines) keep vendor and goal. The one bridge is the connection form's slug error: validateVendor's sentence is shared with a model's refusal, so lib/connection-form.ts rewords it for the field.

find_tool offers Setup in the chat, as a card where the client renders one (GRA-210; GRA-202, The in-chat door; ADR 0024). For an agent whose person has no connection at all (revoked rows count, as the show rule counts them) and whose Setup is neither completed nor skipped (@graft/core's shouldOfferSetup), find_tool answers setup: { url, message } beside tools and connections: url is setupUrl(GRAFT_CONSOLE_URL, agentId), /setup?agent=<id>, and message is handoff-message.ts's setupOfferMessage in the console form (GRA-55's relay clause). The record is read (getSetupRecord, McpDeps.setup) only when the person's connection count, which find_tool already holds, is zero; packages/mcp/src/setup-offer.ts is the rule's home. A record running as another of the person's active agents suppresses the offer, since the page would resume Setup as that agent (GRA-216's review). It is not an ask: no pending action, no signature, no expiry, isError unset, and answer_ask has nothing to admit. For a clientRendersCards session the message takes its card form, cardShown: true rides inside setup beside url, and structuredContent.card is a SetupCard (@graft/ask-card/shape: { kind: "setup", agentName, url }, beside AskCard in CardData; readCardData reads either). The card (render.ts's renderSetup, dispatched by renderCard) draws a title, a sentence, the agent, a sentence saying to ask again once done, and one button, Set up your first tool, that opens the URL with from=card through ui/open-link; it polls nothing. Where the host refuses the window, the card shows the bare URL to copy (setupOpenRefusedOf), since the card-form message tells the model not to send a link and to give that URL only when the person says they cannot see the card or it could not open Setup. One card per session (GRA-212): the card, the card-form message and cardShown ride on the first find_tool answer of an MCP session that carries the offer, held per session in setup-offer.ts as clientRendersCards holds its verdict; every later answer in that session carries setup in the console form and no card, since a host mounts the card for every result of a tool that names it and ChatGPT called find_tool five times in one turn. A session the server re-opens (GRA-129) is a new one. find_tool's description gained one capability sentence and SERVER_INSTRUCTIONS is unchanged. The wide event counts setupOffered: true when it was made. The console's /setup validates ?agent=&from= (lib/setup-page.ts's readSetupSearch); the harness step starts as the named agent when it is one of the person's active agents, even among several (agentToAdopt); a page the card opened shows an Alert saying so and, once POST /api/setup/finish succeeds with no token, posts { type: "graft:ask", setup: "completed" } (card.rules.ts's setupCompletedMessage) to its opener and closes itself after FROM_CARD_CLOSE_MS (afterSetupFinish); a finish that issued a token stays, since the token is shown once. packages/mcp/src/setup-offer.test.ts is the suite.

Screens follow Cando's patterns (GRA-47). Every list is a DataTable layout="grid" with the column widths declared on TableHead — a mobile width and an md: one, the prose column left auto — and DataTableRow for the 40px rhythm; a column the row cannot afford at 390px steps out (hidden md:table-cell) and, where it is the row's one load-bearing fact, follows the name in muted text instead. Loading, empty and failed are rows inside the body, never a spinner or a block beside the table: components/table-body-states.tsx holds TableLoadingRows (one full-span skeleton per row) and TableBodyNote (one full-span sentence), and the failed note carries components/retry-notice.tsx, Cando's inline Retry. A table owns its read (useQuery, not the suspense form) so those states are reachable. The agents table starts its reads without awaiting and paints its own pending rows. Connections stay cards — each carries a status, hosts, tools, two actions and a table — with Cando's card anatomy, and the recent calls are a disclosure in the body, not the banded CardFooter. A notice inside a form is an Alert; a labelled control with a sentence beside it is an Item (components/ui/item.tsx, ported with the rest); nothing draws its own rounded-* border frame. The settings screen is Cando's row family, ported to components/settings/ — SettingsSection (an h2), SettingsCard, SettingsRowGroup with inset dividers, and the SettingsRow variants, App v2's type (GRA-331) — with three sections. Account: the name (Better Auth's /update-user), the address with its verified chip, a row per provider GET /api/sign-in-methods names (linkSocial, unlinkAccount, returning to /settings?provider=<p>[&error=<code>]; lib/account-settings.ts's canDisconnect never offers removing the last way to sign in the door still shows, and a linked account may carry another address, ADR 0020 as amended 2026-10-14), and GRA-298's password row. Appearance: the Theme row, a ToggleGroup writing the one next-themes setting the account menu's radio group writes. Building tools: the Model row, which GET /api/me/model and lib/model-status.ts decide (GRA-297: the deployment's model Included, Your key, Needs a key under the hosted form with no fixed model, Not configured under the open form with none, where a key would not be routed), above Use your own key, whose form is a dialog. A failed query toasts once with a working Retry (lib/query-error-retry.ts, keyed to the query hash so a second failure replaces rather than stacks, dismissed on the next success) beside the mutation toast; a read that fails before a screen draws toasts and shows the route boundary, as Cando's does. A screen-level empty is the Empty primitive without a frame of its own, with a sentence-case title without a full stop and short, concrete supporting copy — except where the screen is a table: there the empty is one muted full-width sentence inside the body, as Cando's connections table and the agents table (GRA-135) do, and the headers stay. An in-card empty is one muted sentence. Console copy has no em dashes. Text-input placeholders use sentence case and a clear prompt; examples belong in helper text. PageContainer gaps: gap-4 under the header of a list screen, gap-6 on a detail or settings screen with several regions, as Cando's connections and settings screens pass them.

Same-origin with the API, in both forms. pnpm --filter @graft/web dev (or pnpm run dev, which starts the server too) serves the app on :3001 with Vite proxying /api and /mcp to GRAFT_SERVER_URL (default http://localhost:3000), and in production apps/server serves pnpm --filter @graft/web build's output from GRAFT_CONSOLE_DIR (default ../web/dist, relative to the server's working directory) with an SPA fallback (apps/server/src/console.ts). The session cookie therefore never crosses an origin; GRAFT_CORS_ORIGIN remains for a console served from elsewhere. A server whose console directory holds no build boots and answers every console path with a JSON 404 saying where it looked. GRAFT_CONSOLE_URL is a different setting: where handoff URLs point (GRA-23), which in development is the Vite origin.

Nothing but the console calls /api with a session, and three rules hold it to that (GRA-148). The session cookie follows the deployment: @graft/auth's sessionCookieAttributes derives it from GRAFT_AUTH_URL, GRAFT_CONSOLE_URL and GRAFT_CORS_ORIGIN: sameSite: "lax" where the console and the API answer on one origin, "none" only where the console is elsewhere, and secure from the scheme, so a self-host on a plain http LAN address can sign in at all. A console elsewhere over plain non-loopback http is refused at boot, because the browser drops that cookie. An origin check on /api: apps/server/src/origin-guard.ts refuses every non-GET request whose Origin, then Referer, then Sec-Fetch-Site: same-origin, is not the auth origin or one of GRAFT_CORS_ORIGIN, with a 403 in the API's own refusal shape. Four kinds of route are exempt and the file says why beside each: /api/proxy/* (a capability token from a sandbox, no cookie), /api/auth/* (Better Auth runs the same check against its own trustedOrigins), /api/health, and reads. Nothing else under /api authenticates by bearer token: the MCP endpoint and the OAuth protocol endpoints are outside the mount. A JSON body is declared: parseBody answers 415 unless the content type is application/json or a +json suffix, which takes every route that reads a body out of CORS's simple-request set, so the browser preflights it; the rule is on the body, so a bare POST with nothing to declare still reaches the emptyIs path. The console's src/lib/api.ts satisfies all three without doing anything special.

The shell is Cando's, less the agent rail (GRA-46). src/components/shell/app-shell.tsx mounts the Sidebar primitive off canvas at its own 16rem — the sidebar_state cookie it writes is read back by src/lib/sidebar-state.ts, ⌘B toggles it, and below md it is the drawer, closed on the router's onBeforeNavigate — with SkipNav first in the tree and the <main> region carrying MAIN_CONTENT_ID. main-sidebar.tsx draws the wordmark (src/components/graft-wordmark.tsx — the Figma library's Logo lockup outlined, also used by AuthHeader, drawn in tokens; GRA-108, GRA-343; the favicons, touch icon and site.webmanifest under public/ carry the same mark) linking to Home at /, the main group and the footer group (Docs & help, out to DOCS_URL, then Settings) from src/lib/main-sidebar-nav-items.ts (a pure data module, tested, which the palette's rows derive from too; GRA-309) with the open-ask count as a SidebarMenuBadge on Requests and the count in the link's own name, and account-menu.tsx at the foot: name and email, the Theme radio group (label inside the group — Base UI's Menu.GroupLabel throws outside one), Sign out through src/lib/sign-out.ts, which removes the session entry and clears the cache before anyone navigates. There is no page strip at md and up (GRA-344): App v2 opens every screen on its own PageHeader, so Cando's 48px PageNav and the title it repeated are gone. Below md, MobileTopBar is the one band, its hamburger opening the drawer and its title the string a screen passes useScreenTitle (shell/screen-title.tsx), which feeds nothing else and does not set document.title. The toggle by the wordmark collapses the sidebar; collapsed, shell/collapsed-sidebar-toggle.tsx floats the same toggle in the content column's top-left corner, and the shell pads the column below 1200px so a page never runs under it. ⌘K (Ctrl+K) opens the command palette (GRA-289), mounted once in the shell beside a Search row under the wordmark (shell/command-palette.tsx, over the ported command primitive and cmdk): its rows, their filter and their groups are src/lib/command-palette.ts (PaletteItem, PALETTE_GROUPS, filterPalette), so a new kind of result is rows and a group name there, never a filter in the component. Every screen is a PageContainer (large for the agents table and the agent detail, medium for the card stacks and settings) opening with PageHeader; a detail screen draws PageBreadcrumb (page/page-breadcrumb.tsx, "Integrations / Gmail") 12px above its header, the parent a link back to the list. The doors share AuthHeader and AuthCard (max-w-md, the xs container inside); route-not-found.tsx, route-error.tsx and loader.tsx are Cando's. Not here, because Graft has none of it (ADR 0017): the agent rail and its faces, the New Thread button, the Automations and Recents groups, and the settings shell with its own sidebar.

The shape is Cando's: routes/_auth/route.tsx is the guard and only the guard (a signed-out visit goes to /login?redirect=<same-origin path> and returns there, which is how a handoff URL survives a fresh browser); routes/_auth/_shell/ is the chrome; a screen's file placement decides both. Three screens sit outside both — the two doors, and routes/oauth.callback.tsx, where the server's OAuth callback sends the popup (GRA-48): it has no session to wait on, no chrome to wear, and everything it shows is in its query. The handoff page sits under the guard but outside the shell (routes/_auth/pending.$id.tsx; GRA-144, ADR 0006 as amended 2026-09-21): the link an agent relays opens one ask under the mark and closes itself once answered — lib/handoff-page.ts decides how, tested — while /pending (the list, in the shell) stays the console's inbox and answers every ask inline. routes/_auth/consent.tsx is the other consent — an MCP client's (ADR 0018) — and sits beside it, under the guard and outside the shell (GRA-219): the guard, so a chat product's "connect" reaches a person with no session by way of sign-in and back, and no shell, because the person is on their way back to the chat and the console's sidebar is only a way out; components/agent/consent-card.tsx is its form, composed from the create-agent dialog's. src/lib/*-queries.ts hold the query options and mutations per aggregate, src/lib/api.ts is the one fetch, and every wire type is imported from @graft/server/api, @graft/core, @graft/db or @graft/mcp and passed through Jsonified<T> — never written a second time. The pending-actions page dispatches on the ask's kind in components/pending/pending-action-card.tsx, one card file per kind, so a new kind is one branch and one file. Components carry no tests; the pure helpers under src/lib do, and check-types runs vite build first so a broken bundle fails CI as a type error would.

The connection form reaches into @graft/core and @graft/proxy at run time, and three modules stay browser-safe for it (GRA-28): packages/core/src/connection/connection.rules.ts is what the form validates with — the same functions the connection service applies at create and the request_connection meta-tool applies to an agent's proposal, so a private, link-local or cloud-metadata host is refused with the reason host_not_public in all three places and again by the proxy at resolution — and packages/proxy/src/credential-fields.ts and scheme-parameters.ts are the two halves of the scheme table the form renders its secret and parameter inputs from. Each imports nothing but the others and a type; an import of @graft/proxy's index or of a repo in one of them pulls node:crypto or drizzle into the bundle, and vite build is what fails. Add a scheme by adding to both tables and the plugin, never to the form.

The console has two doors, and registering opens no session until the address is verified (ADR 0020 as amended, GRA-81, GRA-94): /login signs in only and /signup registers, both Cando's sign-in card — the email, then the password beneath it in the same card, a Continue with Google / Continue with GitHub button under Or for each provider the server names at GET /api/sign-in-methods (public), and a cross-link to the other door carrying redirect. A sign-up answers "check your email" whether the address is new or taken; the inbox tells them apart (@graft/email's emailVerification and accountExists templates), the emailed link verifies, signs in and returns to /login, and an unverified sign-in re-sends the link. The pure decisions are apps/web/src/lib/auth-attempt.ts (every auth call made total over a network failure, with tests) and email-auth-outcome.ts. The providers are GRAFT_GOOGLE_CLIENT_ID/_SECRET and GRAFT_GITHUB_CLIENT_ID/_SECRET, each pair all-or-nothing and off by default (packages/env/src/schema.ts, signInProvidersFrom), handed to createAuth as socialProviders; the redirect URI is GRAFT_AUTH_URL plus /api/auth/callback/<provider>. A social sign-in links to an existing account only when both the provider and the account vouch for the address — which, with verification on, every new password account does. The admin the self-hosted image opens from its environment is marked verified by the boot (markPersonEmailVerified in @graft/db, unscoped and pinned as such), since the operator typed that address. The provider marks are flat .svg files under apps/web/src/assets, outside the colour guard on purpose, as Cando's are.

A forgotten password is reset by email, and mail is a seam (ADR 0021; GRA-82, GRA-90): @graft/email is Cando's @cando/email less the invitation and less the vendor — a transport seam (EmailTransport, one SendResult shape), a registry naming the one template's variables and subject, and the façade. The open form's backings are the SMTP transport (GRA-92) — on when the all-or-nothing pair GRAFT_SMTP_URL/GRAFT_MAIL_FROM is set, sending through the self-host's own relay and rendering each template itself from RENDERERS in packages/email/src/smtp.ts — and, unset, the console transport, which prints the envelope and the reset link into the server's log, so docker compose logs graft is where a fresh self-host's reset link is. The hosted form's transport is a vendor's and lives in the private package, which answers mail beside the other seams (Backings.mail in apps/server/src/backings.ts); no vendor, template id or mail variable appears in this repository. createAuth's passwordReset option binds the hook; the link is GRAFT_CONSOLE_URL plus /reset-password?token=…. The screens are /forgot-password (the same answer whether or not an account exists) and /reset-password (apps/web/src/lib/reset-password.ts decides the dead-link state and folds the outcomes, with its test); Forgot password? is on the door's password step.

Running acquire locally

acquire is the loop (ADR 0004): the meta-tool creates a job and the in-process runner (@graft/mcp's acquire/runner.ts, GRA-29) works it — reads the documentation, drafts, checks, proves with reads, publishes, dry-runs, retries, promotes. A job's publish moves no pointer (GRA-77): it publishes with activate: false, dry-runs the version by id, and on the pass calls @graft/core's activateToolVersion — definition and pointer, one transaction — before promoting, so authored_tool.current_version_id names only a version that passed its dry run (ADR 0012, L0 as amended 2026-09-17); a job that never passes leaves a tool with no current version, which find_tool omits and promote and a run refuse as tool_has_no_version. The dry run runs the version against the job's connection, never the tool row's default, and the publish rebinds an existing row to it when the row's default is revoked (GRA-122; the paragraph A tool follows its vendor's reconnected connection above). The runner is the second plain scheduler beside the sweep: GRAFT_ACQUIRE_CONCURRENCY (default 2) jobs at once, kicked by the meta-tool and polling for what a previous process left queued or running with a stale heartbeat. Each job is bounded by GRAFT_ACQUIRE_MAX_ATTEMPTS (default 4 — every draft is an attempt, a check refusal included) and GRAFT_ACQUIRE_TOKEN_CEILING (default 400000 tokens across every model turn); a job that hits either ends with a result naming it. Every attempt is an acquire_attempt row, every step an acquire_trace line, redacted on the way in (@graft/core's redaction.ts; the proxy redacts an echoed credential by value before that, ADR 0010 amended). A publish refused only as draft-missing — nothing at a draft the check just read — is the toolbox store's miss, not the module's (GRA-123: the hosted store's view of a path another sandbox wrote can lag), so job.ts asks the store once more before the model is shown it.

Which model answers is GRAFT_MODEL_BACKEND (apps/server/src/model.ts chooses at boot). Unset, the server boots with no model and acquire refuses acquire_unconfigured. scripted plays a JSON file of canned answers, one per situation the job puts (@graft/model/scripted, parseScript has the shape), for driving the whole loop on a laptop with no provider key; it needs GRAFT_MODEL_SCRIPT=<path> beside it and is refused in production. provider is the real thing (@graft/model/provider, GRA-31): one strong coding model authors and one cheap model triages — decides whether the job opens with a round of documentation, condenses a long page before it enters the authoring context, never writes code — through the AI SDK, with GRAFT_MODEL_PROVIDER (anthropic | openai) and GRAFT_MODEL_API_KEY read together under it and refused outside it. GRAFT_MODEL_AUTHORING and GRAFT_MODEL_TRIAGE default per provider — Anthropic claude-fable-5-1 / claude-haiku-4-5-20251001, OpenAI gpt-5.6-sol / gpt-5.4-mini, the OpenAI pair confirmed against the live models list on 9 September 2026 — and GRAFT_MODEL_BASE_URL points the OpenAI provider at an OpenAI-compatible gateway, which also selects Chat Completions over Responses. The model answers in the ModelAnswer shape through structured output; an answer the job could not use goes back once, with the problems named, before the job records model_failed. The self-hosted form refuses to boot without it (ADR 0014): NODE_ENV=production under GRAFT_BACKINGS=open needs GRAFT_MODEL_BACKEND=provider with the provider and the key, and the boot names both.

A person's own key (ADR 0014) is person_model_key: one row per person, the key envelope-encrypted under the person's model-key scope through the vault's encrypt half, write-only after entry. GET/PUT/DELETE /api/me/model-key are the console's routes, behind the Settings screen; model.ts decrypts a key in the one place outside the proxy binding and puts the person's provider in front of the fixed model, so their jobs — and the vendor documentation those jobs read — go to their provider and nobody else's (apps/server/src/model.test.ts proves the isolation through the service seam). Routing applies whenever a fixed model exists, and always under cloud; under open with no fixed model acquire refuses at the door rather than accepting a job that fails for want of a key. The key's base URL is a host this server calls, so it is held to the proxy's address rule (GRA-338; ADR 0010): setPersonModelKey refuses one whose host isPublicHost refuses — private, loopback, link-local, carrier-NAT, metadata, unique-local, localhost, single-label or internal, an IP in any spelling the URL parser canonicalises — with a baseUrl sentence and details.reason: "host_not_public", and refuses plain http unless ModelKeyDeps.requireHttps is false, which index.ts sets outside NODE_ENV=production. At call time model.ts hands a person's provider with a base URL @graft/proxy's createGuardedFetch (ProviderModelDeps.fetch): the literal judged before the request, the resolved address judged inside the lookup the socket opens through, redirects never followed, so a rebound name or a row saved before the rule is refused. The operator's GRAFT_MODEL_BASE_URL and a key with no base URL get the platform's fetch. A failed model turn ends the job with @graft/model's modelFailureSentence (the status and what to check, never the endpoint's message or body); the raw error goes to the job's trace alone. A key is checked with its provider when it is saved, and a refused one mid-job is named as the person's (GRA-340): ModelKeyDeps.checkKey, which index.ts binds to model.ts's createModelKeyCheck (@graft/model's checkModelKey: the provider's models list through the guarded fetch, bounded by MODEL_KEY_CHECK_TIMEOUT_MS), refuses a key the provider answers 401 with a 400 on apiKey (details.reason: "key_refused"), and saves one it could not ask, or that was denied the list (a 403: a restricted key may still generate), with warning beside modelKey in the PUT answer. model.ts wraps a person's adapter in withOwnKeyRefusal, so a 401 or 403 mid-job is OwnModelKeyRefusedError and the job ends model_key_refused with "Your own model key was refused by <provider>. Update it in Settings."; the console's progressCard reads it as keyRefused, and Setup's building step and the integration page's Get a new tool dialog link to /settings (exempt from the Setup intercept) instead of suggesting the task change. createGuardedFetch takes no dispatcher: its suite swaps undici's Agent (packages/proxy/src/guarded-fetch.test.ts).

Observability is three seams with no backing in the open form (GRA-100; ADR 0002 as amended 2026-09-19; @graft/observability): the log drain (LogDrain) — where a wide event goes after stdout; the open form's stay on stdout, the hosted form's drain is handed to initLogger({ drain }) on the logger rather than the Hono middleware, so the acquire runner's and the sweep's own log lines drain beside the requests' — analytics (Analytics, NO_ANALYTICS, the event vocabulary in events.ts: noun_verbed, counts and kinds, never content) and model telemetry (@graft/model's ModelTelemetry and NO_TELEMETRY, the backing as ModelTelemetryBacking). Backings carries the three and the boot line names each: logs stdout, analytics off, model telemetry off on every self-host. Every POST /mcp event carries the tool call under mcp — the tool, its kind, the agent, the person, the outcome, the refusal's reason, the latency — from McpDeps.onToolCall, which tools.ts fires once per call from its one dispatch point, and a run of a stock copy or a remix of one adds mcp.stock: the stock tool and version, remix, and on a failure its kind and the vendor's last error status, never the input, output or body, which tool_called carries flat (GRA-244; stock-signal.ts, the runner's VENDOR_STATUS_MARKER; a response the proxy marks x-graft-refused, which every proxy refusal carries, is never a vendor's status); a /mcp request the door or the SDK's transport refuses before any tool runs carries the refusal under mcpRefusal — status, JSON-RPC code, the answer's own sentence, whether a session was named, and the agent once the token resolved to one — from McpDeps.onTransportRefusal (GRA-131: a bare 400 in the log was a guess; GRA-164 added the agent). A ServiceError the API answers is a refusal, not the request's error (GRA-164): api.onError clears Hono's c.error for it and puts refusal: { code, message } on the event, so a consumed handoff link's 409 is an info line with no stack, and only a throw that is not a ServiceError is logged as the event's error; the runner's and the sweep's lines ride under acquire and sweep. Product events are captured server-side at two chokepoints and nowhere in the console: the API's mutation routes (apps/server/src/analytics-routes.ts, one table from method and path to event) for what a person does there, and the MCP hook and the acquire runner for what happens over MCP (tool_called, acquire_completed, acquire_failed); both name the person by id. The Setup read, GET /api/setup, is the one other place (ADR 0024; GRA-206): a step the person completes elsewhere, an ask answered in the inbox or a chat's card or a job that finished, is learned on the read, so setup_step_completed is captured there, once, when the guarded move of the record succeeds. The vendors behind the hosted form and their variables are graft-cloud's, in its private package's observability/ and env.ts. A sign-up is the one event the account raises itself (GRA-157): createAuth's onPersonSignedUp fires from Better Auth's own hooks when a person exists and is verified — the verification click for a password account, the creation for a social account its provider vouches for — and the server captures person_signed_up with method and, alone among events, a person property: the email, through Capture.person, so the profile the alpha's events land on has a name someone can act on. distinctId stays the id. The hooked-up Slack message is PostHog's own destination on that event in the hosted project, not code.

Rate limiting is a seam, and the open form is unlimited by default (GRA-149; ADR 0002 as amended 2026-09-19; ADR 0018's "a rate limit at the edge"; @graft/ratelimit). RateLimiter is one check({ bucket, key, now }) answering allowed, or refused with the seconds to wait, over a fixed bucket per door: sign_in (Better Auth's writes under /api/auth/*), oauth_register (POST /mcp/oauth/register, the unauthenticated write ADR 0018 names), oauth_token, mcp, proxy and api. UNLIMITED is the no-op and the default in both forms, so a self-host refuses nobody until its operator says otherwise and the boot line reads rate limit off. The open backing is createMemoryRateLimiter, a token bucket per key held in this one process (two replicas hold two counts), switched on one bucket at a time by GRAFT_RATE_LIMIT_<BUCKET> of the shape <limit>/<windowSeconds>; set any and the boot line names them (rate limit in-process sign_in 20/60). apps/server/src/rate-limit.ts is the server's half: one Hono middleware rateLimit(limiter, bucket, keyOf) mounted above each door, which answers 429 with Retry-After in that door's own body shape (the API's { error, message }, the proxy's and /mcp's { error, reason, message }, the OAuth endpoints' { error, error_description }) and puts rateLimited: { bucket, key } on the wide event, the key a digest when it is an address. Keys are the person for api and the connection for proxy, where the request names one, and the client's address for every door that runs before anyone is authenticated, mcp included: the socket's peer unless GRAFT_TRUSTED_PROXY_HOPS=<n> says how many hops are in front, because anyone may send X-Forwarded-For. Never a key taken from a bearer token, which is why mcp is the address even when one is presented: an unknown grft_ costs requireAgent a database read, which is the cost the door rations, and a caller inventing a bearer per request would otherwise buy a fresh allowance each time. A per-agent count would have to sit after requireAgent, where it no longer saves the read. A refusal happens before the handler, so it is never an approval, a tool call or a vendor call. Backings.rateLimiter is the seam beside logs, analytics and model telemetry; the hosted form's limits and any store behind them are graft-cloud's, in its private package.

cat >> apps/server/.env <<'ENV'
GRAFT_SANDBOX_BACKEND=fake
GRAFT_MODEL_BACKEND=scripted
GRAFT_MODEL_SCRIPT=./acquire-script.json
ENV

A script for a public API the proxy can reach without a real key — the connection still needs a credential entered, since the scheme injects one — is the shortest by-hand proof: goal → write_module with a ctx.fetch of a documented GET, proofReads naming the same path, and a testInput. acquire { connectionId, goal } over MCP waits GRAFT_APPROVAL_WAIT_SECONDS for the job and answers acquire_status's shape — settled with result when the job finished in time, else { jobId, status, progress, attempts }; acquire_status { jobId, after? } waits up to twenty seconds for a progress line past after (the count already seen) or the end, then answers the progress lines and, at the end, result — the tool's wire name, version and annotations, or { failure, message, lastDiagnostics, tried } (GRA-125: a chat model that got the same lines back seven times in twelve seconds abandoned the job and ran its own code through execute__).

The evals

packages/evals (ADR 0012: the eval suite is the gate; GRA-31) runs the real loop against fake vendors behind the real proxy with the provider-backed model and grades what it did with deterministic scorers: reads before publish, publish before the first write, no vendor host in the model's code, a dry run before any ask, the first write through the published tool, and the supporting facts. The blob scenario (GRA-191; ADR 0023) runs the loop twice on one world, a producing tool against a fake that serves a 3 MiB file and a consuming tool against a fake that takes a multipart upload, the first tool's blob:// ref handed into the second's goal and input, and adds five scorers over the chain: no sentinel of the file in any model turn, the ref answered and carried, the consuming dry run given a blob to read and its write intercepted, the second vendor's sha256 equal to the first's, both modules on ctx.blob. It is an app-like leaf nothing imports, which is what keeps it out of the server's Docker image (apps/server/Dockerfile's prod-deps installs --filter "@graft/server..."); a server dependency on it would pull it in.

pnpm --filter @graft/evals eval                      # every scenario; needs GRAFT_MODEL_PROVIDER + GRAFT_MODEL_API_KEY
pnpm --filter @graft/evals eval -- --scenario write  # one by name
pnpm --filter @graft/evals eval -- --scenario blob   # the two-vendor blob chain
pnpm --filter @graft/evals eval -- --scripted        # the harness's own test: canned answers, no key, no spend

Without a provider it says what is missing and exits non-zero before opening anything. pnpm test runs the scorers and the scripted harness test on every commit and never reaches a provider. packages/evals/README.md has the scorers and how the SDK scenario runs on the fake sandbox.

The Hermes skill

skills/hermes-graft/SKILL.md (ADR 0016; MIT under skills/LICENSE) is the thin skill a Hermes person installs: when to call acquire, how to relay a handoff, how to describe an approval, what acquire_status means while a job runs, and the mcp_servers block with the agent token in ~/.hermes/.env. Nothing else is installed into the harness; skills/hermes-graft/README.md says how.

Publishing a tool by hand

The publish (@graft/publish, GRA-18) writes a version into the person's toolbox — a directory tree under GRAFT_TOOLBOX_ROOT, default ./.graft/toolboxes, one subdirectory per person (packages/toolbox/README.md has the layout and how the tree meets a sandbox's mount). A module can be published from a directory without the MCP server:

pnpm --filter @graft/server publish-fixture -- --dir ../../packages/publish/fixtures/hello \
  --vendor demo --name hello --description "Greets a name" --email you@example.com --password '…'

A module that declares packages needs the Docker backing for ADR 0013's install step: GRAFT_SANDBOX_IMAGE (pnpm --filter @graft/sandbox-docker image:build makes graft-sandbox:dev) and GRAFT_SANDBOX_NETWORK (an internal network, docker network create --internal graft-sandbox), all-or-nothing. Without them the publish refuses such a module with an install-failed diagnostic saying so. left-pad in packages/publish/fixtures/left-pad is not an official SDK, so admitting it is GRAFT_PACKAGE_ALLOWLIST=left-pad; GRAFT_PACKAGE_MIN_AGE_DAYS and GRAFT_PACKAGE_MIN_WEEKLY_DOWNLOADS are the policy's other two knobs.

check-types, test, build and dev are Turbo tasks, so they run whatever a workspace declares under that script name and nothing for a workspace that declares none. Filter with pnpm exec turbo run <task> -F @graft/<name>. pnpm run test --force skips Turbo's cache; read the summary and confirm Cached: 0 when a green run is the evidence you are after.

Adding a package

A workspace is a directory under packages/ or apps/; pnpm-workspace.yaml globs both, so no file is edited to add one. Copy the shape of packages/core:

  • package.json with "type": "module", exports pointing at ./src/*.ts (packages ship source; tsx and Vite compile it, so there is no build step), and check-types and test scripts so Turbo picks the package up.
  • Shared dependency versions come from the catalog: in pnpm-workspace.yaml (typescript, @types/node, vitest); add a line there rather than pinning a second copy of a version.
  • tsconfig.json extends @graft/config/tsconfig.base.json and sets noEmit; the base is strict, ESM with moduleResolution: bundler, verbatimModuleSyntax and noUncheckedIndexedAccess.
  • Tests are Vitest, colocated as <unit>.test.ts beside the unit, and services are designed to run without a database.

Conventions carried over from Cando, until this repo has its own

Biome for formatting and linting: two spaces, one hundred columns, double quotes, noFocusedTests at error because biome ci exits 0 on a warning and an it.only would otherwise pass the required check. .agents/skills is excluded from Biome because vendored files answer to their upstream. .claude/worktrees is excluded root-relative on purpose: a **/ pattern matches the containing path too, so running Biome inside a checkout that sits under a worktrees/ directory would exclude the whole checkout and lint nothing (Cando's CAN-147; reproduced here before writing the pattern). The vendored engineering workflow is the Skills section above.

Conventions

  • One pnpm override, for a Dependabot alert, not a preference. @esbuild-kit/core-utils>esbuild is pinned to the 0.25 line under overrides: in pnpm-workspace.yaml because drizzle-kit's ESM loader still depends on @esbuild-kit/core-utils, which resolves esbuild@0.18, and esbuild below 0.25 lets any website reach its development server (GRA-41). Nothing here runs esbuild's serve mode, so the exposure was theoretical; the override exists so the alert closes. Drop it when drizzle-kit stops depending on @esbuild-kit (pnpm why @esbuild-kit/core-utils says whether it still does). It is in the workspace file rather than under pnpm in the root package.json because pnpm 11 no longer reads that field and warns on every command that it ignored pnpm.overrides, while pnpm 10 — the line packageManager pins, and what an 11 on a laptop delegates to — reads both places, so the workspace file is the one placement both lines honour.

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.