Documentation

Framed tools: build a connection in one file

Reference for developers and partners who connect a system to Framed. Version 2026-10-12.

A workspace's most specific abilities are not Framed's. They are the systems its people already use: a ledger nobody else's AI has heard of, a booking system, an internal API. A tool makes one of those callable from Framed: a worker step reads it on a schedule, a person's own AI reads it over Connect, and everything it changes waits for a person's approval. This page is how you describe one, test it on your machine, and put it on a workspace.

Base URL: https://www.framed.dev/api. The same key and the same workspace gate as the Developer API and the Platform API.


Contents

  1. Quick start
  2. The file
  3. Read, write, sensitive
  4. Secrets
  5. Fixtures
  6. The local runner
  7. Defining it on a workspace
  8. What a host sees
  9. What a worker does with it
  10. Where it shows up in Framed
  11. Reason codes
  12. Schema
  13. Changelog

1. Quick start

One file, one command, one call.

# 1. Describe the tool.
cat > framed-tool.json <<'EOF'
{
  "format": "framed-tool/1",
  "slug": "ledger_open_invoices",
  "name": "Open invoices",
  "description": "The open purchase invoices in the ledger, by supplier.",
  "access": "read",
  "arguments": {
    "type": "object",
    "properties": { "supplier": { "type": "string", "description": "Supplier name" } }
  },
  "kind": "request",
  "request": {
    "method": "GET",
    "url": "https://ledger.example/api/invoices?supplier={supplier}",
    "headers": { "Authorization": "Bearer {{secret}}" }
  },
  "auth": { "secret": "CONNECTOR_LEDGER" },
  "fixtures": [
    { "example": { "supplier": "Acme" }, "label": "two open", "response": { "rows": [{ "id": 1 }, { "id": 2 }] } },
    { "response": { "rows": [] } }
  ]
}
EOF

# 2. Check it and see what a host will see. Nothing leaves your machine.
npx framed-tool test framed-tool.json

# 3. Put it on a workspace. Same check, then it is the workspace's own tool.
FRAMED_API_KEY="frk_live_…" npx framed-tool test framed-tool.json --push --workspace WORKSPACE_ID

After step 3 an administrator pastes the value of CONNECTOR_LEDGER on the tool under Connections, and it is live: framed_list of:"tools" names it, framed_tool calls it, and a worker plan can read it with tool_call.


2. The file

framed-tool.json is one JSON object. The same file defines the tool over the API and reads back from a workspace, so it can live in your repository.

FieldRequiredWhat it is
formatyes"framed-tool/1"
slugyesThe name a plan and a host call. 2 to 80 characters of a-z, 0-9, _, -.
nameyesWhat people see. At most 120 characters.
descriptionyesOne line, what it does, in the words a host reads to decide whether to call it. At most 400 characters.
accessyes (not for mcp)read, write or sensitive. See §3.
argumentsyes (not for mcp)JSON Schema for the arguments, type: "object". {"type":"object","properties":{}} for none. Give every property a description: a host reads them.
kindyesrequest (an HTTP call), fixture (answers from the fixtures only), mcp (an MCP server, see below), code (reserved, refused today).
mcpfor mcpurl (https, the server's Streamable HTTP endpoint, no user or password in it) and auth: {"secret": "CONNECTOR_…"}, the NAME of the bearer token the server takes.
requestfor requesturl (https only), method, headers, query, body. {name} in the url, query or body is replaced by the argument of that name; {{secret}} by the secret's value.
authno{"secret": "CONNECTOR_…"}, the secret's NAME. Never a value. See §4.
fixturesyesAt least one, at most 20. See §5.
capabilitiesnoWhat a process or worker may execute. Each item is { "id": "contact.read", "access": "read", "operations": ["list", "get"] }. Ids are noun.read. A .write id or "access": "write" is refused when the file is read. Omit the field and the tool is still called by slug. When it is set, any other id or operation is denied.
clientno"moneybird" when live calls should use the Moneybird client inside the connector runtime. Anything else is refused.
piinoAccepted for older files (fieldAction, denyFields, allowFields, scanText); not applied since 14 Oct 2026.
confirmnotrue asks a person even for a read.
restrictednotrue makes the tool visible only to the people named on it in Framed.
limitsnoWhat the tool never does, one plain sentence each: ["Stages a concept only.", "Never books, pays or deletes an invoice."]. At most 5, 200 characters each. Framed shows them beside every write an approval makes with the tool (the Platform API's writes[].limits). Omit the field to keep what the tool has; [] clears it.
follow_upnoFor a tool that writes: what Framed follows up after an approved call, until it is done in the other system. See Follow-up. Omit the field to keep what the tool has; null clears it.

auth.env is an optional map of further secret names (clientId, clientSecret, redirectUri, accessToken, administrationId), each a CONNECTOR_ name. A value in that map is refused. On a live call, each name is looked up on its own in the workspace secret store. The value is the secret itself (the bearer, the administration id), not a JSON bundle.

Capabilities

A process does not ask for Moneybird or for a slug when the tool declares capabilities. It asks for a capability id:

{ "action": "tool_call", "tool": "moneybird", "capability": "contact.read", "operation": "list" }

operations is list, get, or both. A write operation (create, update, delete, write, patch) is denied even if someone sends it, and it cannot be declared. The check runs in the connector runtime (executeDeclaredCapability in connectors.ts) before a fixture, the Moneybird client, or any other HTTP call.

The file field is stored on v2_connectors.declared_capabilities. That column is not v2_connectors.capabilities, which remains the internal grant list (read:insights and the like). A tool definition does not grant those. Moneybird ships as v2/tools/moneybird/framed-tool.json with contact.read and sales_invoice.read.

A worker step that names a capability the tool does not declare, or an operation that capability does not list (including a missing operation), fails the plan and, if it is run anyway, the call. The model-bound result does not include the body of a denied call. The audit line (connector=… capability=… operation=… mode=… ok=… truncated=…) is written on the tool-call result and in front of an agent connector pull. It has no response body.

An MCP server

A system that already speaks MCP is one file, without describing its calls:

{
  "format": "framed-tool/1",
  "slug": "issue_tracker",
  "name": "Issue tracker",
  "description": "The team's issue tracker, over its MCP server.",
  "kind": "mcp",
  "mcp": { "url": "https://mcp.tracker.example/mcp", "auth": { "secret": "CONNECTOR_TRACKER_TOKEN" } }
}

It becomes one tool on the workspace with two actions:

{ "action": "list" }
{ "action": "call", "tool": "search_issues", "arguments": { "q": "login" } }

list answers the server's tools (read again after ten minutes), each marked by what a call does. Framed reads the server's own annotations, and reads them strictly:

The server saysOn a call
readOnlyHint: trueRuns, in chat, over Connect and in a worker step.
nothing, or readOnlyHint: falseChanges something: in chat a card waits for a person's approval; anywhere else it is refused.
destructiveHint: trueAs above, marked sensitive.

A worker's tool_call reaches only the read-only tools. "access": "write" or "confirm": true on the file asks for an approval on every call, reads included; a file can make the server stricter, never looser. Framed speaks to the server over https only, never to a private or internal address, sends the token only to the address in the file (never across a redirect), waits at most 30 seconds and reads at most 1 MB per request. capabilities and request are refused on an MCP file: the server says what its tools do. Fixtures are optional.

A file with something wrong is refused in words, not stored half right. The runner and the API say the same things.


3. Read, write, sensitive

access decides what a call does.

accessOver Connect (framed_tool)In a worker step (tool_call)In chat
readRuns on the call; the answer comes back at once.Runs inside the run.Runs.
writeProposes: a card under Tasks with every argument; a person says yes; nothing runs on the call.Refused by the runner: the plan puts an approval step before it and the write runs from the review queue.Asks first.
sensitiveAs write.As write.Asks first.

confirm: true turns a read into a proposal too. There is no switch that lets a write run without a person. A partner cannot add one.

Follow-up

A write is often not the end of it: an invoice staged as a concept still has to be booked by someone in the other system. A tool that writes can say what Framed should follow up after an approved call went through, and how to tell it is done, with another tool of the workspace that only reads:

"follow_up": {
  "what": "Concept invoice {{args.invoice_number}}",
  "until": "Someone books it in the ledger",
  "check": { "tool": "ledger_list_booked", "args": { "invoice_number": "{{args.invoice_number}}" } },
  "done_when": { "all": [{ "path": "data.items.invoice_number", "op": "equals", "value": "{{args.invoice_number}}" }] },
  "alarm_after_days": 7,
  "stop_after_days": 30
}
FieldRequiredWhat it is
whatyesWhat is followed up, one line a person reads.
untilyesWhen it counts as done, one line.
check.toolyesThe slug of the tool that checks. It must only read (access: read, no confirm) and answer from the live system; Framed never runs anything else on its own.
check.argsnoIts arguments.
done_whenyesThe condition on the check's answer, as an event filter: all and any of { path, op, value }, op one of equals, not_equals, contains, one_of, exists, above, below, at most 8. Say done positively: not_equals only beside a condition that is not, because an absence also reads as done when an answer changes shape. Compare ids and numbers: names and addresses reach Framed hashed.
alarm_after_daysnoDays it may wait before Framed says so, once. 1 to 30, default 7.
stop_after_daysnoDays after which Framed stops following it, quietly. 1 to 90, default 30, never before the alarm.

{{args.<key>}} and {{result.<path>}} in what, until, check.args and a condition's value are filled from the call that wrote: its arguments as they went out and its answer ({{result.data.id}}). A placeholder that finds nothing means no follow-up for that call, never a check with a hole in it; a file's bytes and anything shaped like a credential are never filled. In a chain, the last call whose tool asks for a follow-up gets it, one per approval.

What Framed then does, by rule: it checks two hours after the write, then after four, eight and twelve hours, then once a day; it says nothing while it waits and nothing when it is done; it says it once when one waited alarm_after_days (one finding a week per workspace on Catch up, for all that turned); a failing check is never said by itself; it stops by itself after stop_after_days. A person can stop it sooner. The Platform API's approval carries where it stands (follow_up).


4. Secrets

A tool file never carries a credential. It names the secret:

"auth": { "secret": "CONNECTOR_LEDGER" }

The name is capitals, digits and underscores, and starts with CONNECTOR_, so a tool can never reach a platform credential. The value is pasted on the tool in Framed under Connections by a workspace administrator, stored encrypted per workspace, and read only when the tool runs. Reading a tool back from a workspace returns the name, never the value.

The file is refused when it carries anything that looks like a value: an auth.value, auth.token, auth.api_key, a lowercase secret name, or a bearer with more than a placeholder in a header.

A secret goes only to the host the tool declares. The host in request.url must be written out when the tool uses a secret: a placeholder may stand for one label of it (https://{tenant}.example.com/…), never for the whole host, so an argument cannot send the secret to another domain. The same check runs on every call: a request that carries the secret and goes to another host than the tool's own address is not sent. A secret name another tool in the workspace already uses can only be used for that tool's host, and a tool that holds a secret keeps its secret and its host when it is defined again. The names of a built-in connection's secrets (the client ones) cannot be used by a custom tool. A tool defined from a file never reads a platform secret from the server's environment, only the value pasted on the tool in the workspace.


5. Fixtures

A fixture is an example call and the answer it gets. Two reasons to write them: the tool can be tested without the live system, and a fixture tool answers from them and nothing else.

{ "example": { "supplier": "Acme" }, "label": "two open", "response": { "rows": [{ "id": 1 }] } }

A call gets the first fixture whose example is a subset of its arguments, else the fixture with no example, else the first. The check refuses an example that names an argument the schema does not declare, and one that another fixture would answer first.

A tool that declares capabilities puts capability and operation on every fixture example (and id when the operation is get). The check refuses an example that leaves them off.

A fixture answer is marked answers_with: "configured example" everywhere it goes. A host is told that relaying it as current numbers is inventing them, and it is never kept as a read.


6. The local runner

npx framed-tool test framed-tool.json
npx framed-tool schema

test parses the file, runs the fixtures against the definition and prints what a host will see for each example: whether it runs on a call, whether it answers live or from a fixture, how many rows, the first row, and what Framed keeps. It exits 1 with the problems listed when something is wrong. The check is the same code the API runs, so a file that passes here passes live.

--push --workspace <id> then defines it over the API with FRAMED_API_KEY from the environment (FRAMED_API_BASE for a stack that is not www.framed.dev). schema prints the JSON schema of the file.

The runner is in the @framed/connect-mcp package (v2/mcp). Nothing in it reads a secret value.


7. Defining it on a workspace

POST https://www.framed.dev/api/tools/define
X-Framed-Api-Key: frk_live_…
Content-Type: application/json

{ "workspace_id": "…", "tool": { …the file… } }

Grant: use_connectors on the workspace for the person the key acts as. A Connect token acts as the person who consented; a raw key as the administrator who issued it; a reseller key marks the tool partner_provided.

Answers 200 with:

FieldWhat it is
toolThe slug.
refconnection:<id>, the tool's ref in the object graph.
replacedtrue when a tool with this slug already existed on the workspace and was replaced.
runs_on_callWhether a call runs it or proposes it.
needs_secretThe secret's name when the tool needs one, else null.
checkThe runner's lines.
examplesPer fixture example, what a host sees (§8).
definitionThe file as the workspace now holds it.
noteWhat happens next, in one or two sentences.

A bad file answers 400 with reason: "bad_definition" and problems, the same lines the runner prints. Defining a tool runs no model and spends no credits. It replaces a tool of the same slug whole: the fixtures, the request, the secret name. The pasted secret value stays.

What it may replace is narrow. A file replaces only a tool that a file defined (it carries the mark request_config.defined_from), for the same owner: the person behind the call, or a key of the same kind when no person is behind it. A tool built in Blueprint by someone else, seeded or installed, shared across the account, or defined by another person is never replaced; the answer is 409 with a reason (§11) and hint. A file cannot change a live tool into a fixture one or the reverse, cannot make a tool that waits for an approval run on a call, and cannot move a tool that holds a secret to another host or another secret. Tools defined before this rule have no mark: their own owner may still replace them, and a key with no person behind it may replace a partner tool; anything else is removed under Connections first.

GET /api/tools/<slug>?workspace_id=… reads a tool back with what it last read. framed_list of:"tools" lists them.


8. What a host sees

A person's own AI (Claude, ChatGPT, Cursor, anything that speaks MCP) reaches the tool through Framed Connect as framed_tool. It sees:

{
  "ok": true,
  "tool": "ledger_open_invoices",
  "tool_name": "Open invoices",
  "ran": true,
  "answers_with": "live system",
  "result": { "rows": [ … ] },
  "kept": { "kept": true, "rows": 2, "read_at": "…" }
}

or, for a tool that changes something (HTTP 202):

{
  "ok": true,
  "ran": false,
  "job_id": "…",
  "approval_id": "…",
  "status": "awaiting_approval",
  "tool": "ledger_book_invoice",
  "tool_name": "Book an invoice",
  "review_url": "https://www.framed.dev/tasks",
  "note": "…"
}

Never handed to a host: the request, the secret name, the fixtures. How a tool works is not what it does. The runner's output is exactly this shape, per fixture example, so you see it before anyone connects.

A set of rows a live read returns is kept in Framed per (tool, arguments): the next session, the worker that checks them and the person finishing the work read it from there instead of from the system again. A single answer is kept only when the host asks (persist: true). Keeping it decides nothing.

Codex and Gemini CLI use the same hosted connector when they speak MCP and can finish sign-in. The redirect allowlist is the connector's: any https callback, loopback http on any port and path (how a desktop or CLI host catches the redirect), and a private-use app scheme that is clearly one app's own. Anything else is refused.


9. What a worker does with it

A worker (a process on a channel) reads a tool as a plan step:

{ "action": "tool_call", "tool": "ledger_open_invoices", "args": { "supplier": "Acme" } }

A tool that declares capabilities is called the same way, plus the capability and the operation (list or get). The runner executes only a capability that definition declares. See §2.

Framed compiles that step when the worker's instruction names the system, and refuses a write tool there: the plan puts an approval step first and the write runs from the review queue. The step's extract (rows first, clipped) feeds the steps after it. A worker that reads what other workers found uses read_objects beside tool_call, never instead of it.

Every run that reads a tool writes run read_from connection:<id> into the object graph, so a finding can say where it came from and a partner can see their tool used.


10. Where it shows up in Framed

  • Connections: the tool is a row under Systems it works in, with the glyph of its kind, the secret it waits for, and Manage.
  • Workers: a worker that reads it carries its glyph; its Knowledge and tools tab says what a run does with it (reads, writes, asks you).
  • Tasks: a write waits there as a card with every argument it would send.
  • Log: every call, with who asked and what came back, clipped.
  • Catch up: a finding a run made from it links back to the run and the run to the tool.

11. Reason codes

reasonWhereMeans
bad_definitiondefineThe file or its fixtures are wrong; problems says what.
store_faileddefineFramed could not write the row, or could not check what it would replace. Try again.
slug_taken_shareddefineA tool shared across the account has this slug. Choose another.
owned_by_someone_elsedefineA tool with this slug was made by someone else. Choose another slug, or have an administrator remove it under Connections.
slug_taken_other_kinddefineThe tool with this slug was not defined from a file by this kind of connection, or is a different kind (live or fixture).
would_loosen_gatedefineThe existing tool waits for an approval; a file cannot take that away.
would_move_secretdefineThe existing tool holds a secret; a file cannot give it another secret or host. Use a new slug and a new secret name.
secret_bound_elsewheredefineAnother tool already uses this secret name for another host. Use another name.
reserved_secretdefineThe secret name belongs to a built-in connection.
host_from_argumentdefineA tool with a secret must write its host out; only one label of it may be a placeholder.
access_policydefine, callThe person the key acts as lacks the grant; grant names it, where says who changes it.
no_such_toolcallNo tool of that name on this workspace; tools lists the ones there are.
bad_argumentscallArguments do not fit the schema; missing and unknown name them.
tool_errorcallThe tool ran and the system did not answer well. The only reason that means Framed reached it.

12. Schema

npx framed-tool schema prints the JSON schema of the file. The same object is FRAMED_TOOL_SCHEMA in supabase/functions/_shared/framed-tool.ts, which is the one module the runner, the API and the tests read.

kind: "code" (a tool as a small program that runs in its own machine) is reserved and refused today. It is on the roadmap; a file that uses it is told so in words.


13. Changelog

  • unreleased: follow_up on a tool that writes: what Framed follows up after an approved call, with a read tool of the workspace, until it is done in the other system. Stored as v2_connectors.follow_up. Additive.
  • 2026-10-12: kind: "mcp": an outside MCP server as one tool with list and call; a tool the server marks read-only runs, any other waits for an approval in chat and is refused elsewhere. access, arguments and fixtures are optional for it.
  • 2026-09-29: capabilities on the file, stored as v2_connectors.declared_capabilities and enforced for process and worker calls. Ids are noun.read only. Moneybird is one row from v2/tools/moneybird/framed-tool.json (contact.read, sales_invoice.read) on the existing client. A plan fails when the operation is missing or not declared. Egress is minimized once.
  • 2026-09-28: first version. The file, the runner, POST /api/tools/define, run read_from connection in the object graph.