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
Where files are headingNew AGENTS.md files have more rules about keeping secrets out of the code, and fewer about code style.
Of the AGENTS.md files we read, the 31,439 first seen in the last six months, against the older ones: the kind of rule that gained most share, and the kind that lost most. About files like this in general, not this one, and an estimate, since a kind is a pattern in the words.
In this repository39 other files it gives its agents.
- ask-mattSkill
- claude-handoffSkill
- codebase-designSkill
- diagnosing-bugsSkill
- domain-modelingSkill
- git-guardrails-claude-codeSkill
- grillingSkill
- grill-meSkill
- grill-with-docsSkill
- handoffSkill
- implementSkill
- implement-specSkill
- improve-codebase-architectureSkill
- loop-meSkill
- matt-code-reviewSkill
- migrate-to-shoehornSkill
- prototypeSkill
- prSkill
- researchSkill
- resolving-merge-conflictsSkill
- retroSkill
- scaffold-exercisesSkill
- setup-matt-pocock-skillsSkill
- setup-pre-commitSkill
- setup-ts-deep-modulesSkill
- tddSkill
- teachSkill
- to-questionnaireSkill
- to-specSkill
- to-ticketsSkill
- triageSkill
- wait-whatSkill
- wayfinderSkill
- wizardSkill
- writing-beatsSkill
- writing-for-agentsSkill
- writing-fragmentsSkill
- writing-shapeSkill
- hermes-graftSkill
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
- Very specific: line 7
Read CONTEXT.md. Every term is used exactly as defined there, in code, comments, copy and tickets.
- Very specific: line 9
Read the ADRs under docs/adr/ that touch the area you are about to work in.
- Very specific: line 11
Read docs/roadmap.md for what is in scope now and what is deliberately later.
- Very specific: line 12
Read the pull request body of any ticket you build on.
- Very specific: line 177
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 …
- Very specific: line 224
Use a distinct project name (docker compose -p <name> …) to run a second copy beside a colleague's: the sandbox network and the toolbox …
- Very specific: line 256
Every other suite runs with fakes and no database — packages/core/src/agent/agent.service.test.ts is the reference shape — and …
- Very specific: line 501
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 …
- Very specific: line 512
Every tool that can ask carries _meta.ui.resourceUri unconditionally (Claude.ai declares no extension): acquire, request_connection, …
- Very specific: line 1129
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.
≈ “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.
Files
AGENTS.md
Guidance for coding agents working in this repository. CLAUDE.md is a symlink to this file.
Before anything else
- 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. - 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. - Read
docs/roadmap.mdfor what is in scope now and what is deliberately later. - Read the pull request body of any ticket you build on;
docs/reports/README.mdindexes 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 lookedupdate 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-reviewcannot auto-update. The CLI resolves the upstream skill by its local name rather than byskillPath, so it looks formatt-code-reviewin his repository, finds nothing, and reports a failure. Nothing is damaged, but it receives no upstream changes. To refresh it by hand,addit under its own name somewhere disposable and copy the file across, then put thename:line back.update -pupdates every vendored skill. Run it deliberately, in a pull request about that.- On a laptop with
~/.openclaw,updatelinks the skills into the rootskills/directory. The CLI relinks every agent it detects as installed, and its OpenClaw definition names the bareskills/as that agent's project directory, which here is the Hermes skill's home (MIT,skills/LICENSE). They show up as untrackedskills/<name>symlinks; delete them before committing (find skills -maxdepth 1 -type l -delete) and never commit them. Install only ever namedclaude-codeandcodex, so a freshadddoes 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-bugswith /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 indocs/agents/issue-tracker.md.triagestep 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 editingtriage/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.mdand onedocs/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.
mainis protected. Branch, push, open a PR. Merging needs one approving review, a greenTypecheck, Lint & Testcheck, 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 -swrites theSigned-off-byline theDCOworkflow looks for,git merge --signoffsigns a merge, andgit rebase --signoff origin/mainadds 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 andCONTRIBUTING.mdis what an outside contributor reads. SECURITY.mdis 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.tsor widening whatanswerableadmits. - 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 recordacquire'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 owndenyandaskEveryCallstill win over it. The connection confirmation offers it too (GRA-239), as a second pre-ticked line underapproveBuild, never with destructive tools and never narrowing one that stands (allowVendorWhenConnecting). Read the amendments before changingpackages/core/src/approval/approval.decision.tsor the ask inpackages/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 StudioThe 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, wheretsx watchand Vite reload.apps/server/.envnames the database for it. The fourdb:*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 -dpublishes no database, and thegraftservice 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..envat 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.mjsmints the five Graft refuses to start without andGRAFT_ADMIN_PASSWORDwith them (GRA-148), and the operator typesGRAFT_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 devThe 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
ENVA 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 spendWithout 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.jsonwith"type": "module",exportspointing at./src/*.ts(packages ship source;tsxand Vite compile it, so there is no build step), andcheck-typesandtestscripts so Turbo picks the package up.- Shared dependency versions come from the
catalog:inpnpm-workspace.yaml(typescript,@types/node,vitest); add a line there rather than pinning a second copy of a version. tsconfig.jsonextends@graft/config/tsconfig.base.jsonand setsnoEmit; the base is strict, ESM withmoduleResolution: bundler,verbatimModuleSyntaxandnoUncheckedIndexedAccess.- Tests are Vitest, colocated as
<unit>.test.tsbeside 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>esbuildis pinned to the 0.25 line underoverrides:inpnpm-workspace.yamlbecause drizzle-kit's ESM loader still depends on@esbuild-kit/core-utils, which resolvesesbuild@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-utilssays whether it still does). It is in the workspace file rather than underpnpmin the rootpackage.jsonbecause pnpm 11 no longer reads that field and warns on every command that it ignoredpnpm.overrides, while pnpm 10 — the linepackageManagerpins, and what an 11 on a laptop delegates to — reads both places, so the workspace file is the one placement both lines honour.
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
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.

