Skip to content

Documentation

The MCP door

Kicklace speaks the Model Context Protocol. Point Claude Code, Claude Desktop, claude.ai or any other client at one address and it can look people up, run a report, write notes and tasks, set the workspace up, and draft an email for you to send. This is the protocol in full: every request, every answer, and every refusal.

Kicklace speaks the Model Context Protocol. Point Claude Code, Claude Desktop, claude.ai, or any other MCP client at one address, and it can look people up, read a person's whole story, run a report, add notes and follow-ups, put someone on a list with the words they agreed to, and draft an email for you to send.

POST https://www.kicklace.com/api/mcp
Authorization: Bearer kla_…        (a connector that signed in)
Authorization: Bearer sk_live_…    (a key you pasted)

The transport is Streamable HTTP and this server is the stateless half of it: one POST in, one JSON answer out. There is no session id, no SSE stream, and nothing kept between calls, so a client that reconnects loses nothing.

What it will not do

  • It never sends an email. It drafts one, or it proposes one — and you approve it on the Approvals page, from your own account. See Proposals.
  • It never puts anyone on a list without the words they agreed to. A consent sentence of 10 to 500 characters is stored verbatim beside the subscription, with the source "added by <your name> via Claude".
  • It never deletes anything on its own, never mints or reads API keys, and never changes a setting beyond the one its item names: allow_site connects a website address, and nothing touches a key, a sending domain, a plan or a member without a person approving it first.
  • Every write carries your name, "via Claude", on the record's timeline.
  • Every call it makes — refused or not — is one line under Settings → Claude.

Signing in (OAuth 2.1)

A connector list — claude.ai's, Claude Desktop's, Claude on a phone — does not paste keys. It finds the sign-in from the door itself and sends the person to Kicklace, and what it gets back is a token of its own that the person can take away again. Kicklace is its own authorization server for this; there is no third party in it.

Every 401 from /api/mcp says where to look, whether or not a token was sent:

WWW-Authenticate: Bearer realm="kicklace",
  resource_metadata="https://www.kicklace.com/.well-known/oauth-protected-resource"

The two metadata documents. Both are public, cached a minute, and readable from any origin.

Address What it says
GET /.well-known/oauth-protected-resource RFC 9728: resource is https://www.kicklace.com/api/mcp, authorization_servers is ["https://www.kicklace.com"], scopes_supported is ["kicklace"], bearer_methods_supported is ["header"]
GET /.well-known/oauth-protected-resource/api/mcp the same document, at the path-suffixed address some clients ask for
GET /.well-known/oauth-authorization-server RFC 8414: the four endpoints below, response_types_supported: ["code"], grant_types_supported: ["authorization_code","refresh_token"], code_challenge_methods_supported: ["S256"], token_endpoint_auth_methods_supported: ["none"]

The four endpoints.

Endpoint What it is
POST /api/oauth/register RFC 7591 dynamic client registration. client_name and redirect_uris; answers client_id and the registered values, and no client secret — every client here is public. 30 registrations an hour per address.
GET /connect/authorize the consent screen. response_type=code, client_id, redirect_uri, code_challenge, code_challenge_method=S256, state, resource. A signed-out person signs in and comes straight back; a signed-in one picks the workspace and presses Allow or Deny.
POST /api/oauth/token form-encoded. authorization_code with code_verifier, or refresh_token. Answers access_token (kla_…, one hour), refresh_token (klr_…, thirty days), token_type: "Bearer", expires_in, scope.
POST /api/oauth/revoke RFC 7009. token= either kind. A refresh token takes its whole sign-in with it.

A redirect address must be https://…, http://localhost or http://127.0.0.1 on any port, or a private scheme like claude://callback. It is matched exactly at every step; an address that was never registered is shown on the consent screen and redirected to nowhere.

PKCE is required, S256 only, and the verifier is compared in constant time. A code lasts ten minutes and is spent by the statement that reads it: a code presented twice is invalid_grant and revokes everything the first swap was given. A refresh token rotates on every use, and one presented twice revokes the whole sign-in, because one of the two holders copied it.

Audience. A token is issued for https://www.kicklace.com/api/mcp and the door checks that before it does anything else, so a token minted for somebody else's service is refused with 401.

What it comes to. A token signs in as the member who allowed it, in the one workspace they picked, so it can do what they can do and no more — the same context as their own key. It is listed under Settings → Claude as a Connected app, with a Disconnect that stops it at once.

The two kinds of key

Key Minted at May
Your own (purpose: "claude") Settings → Claude, by you read and write, as you
A server key (purpose: "server") Settings → Your website → For your developers read only

A server key belongs to a machine, so it has nobody's name on it; a note or a follow-up needs one. A write attempted with a server key is refused with:

A server key can only read. Connect Claude with your own key under Settings → Claude to write.

Both are sk_live_… secret keys and both work at /api/v1/events too. Keep them out of a browser: a request carrying an Origin header and a key is refused, because a page must use the public key in the site tag instead. An access token may arrive with an Origin, since a page cannot hold one without somebody having signed in to that app, and this endpoint sends no CORS headers, so a browser cannot read the answer cross-origin either way.

Connect it

Claude Code

claude mcp add --transport http kicklace https://www.kicklace.com/api/mcp \
  --header "Authorization: Bearer sk_live_…"

Then claude mcp list shows it, and /mcp inside a session shows the tools.

Claude Desktop

Claude Desktop does not speak HTTP MCP directly yet, so it needs mcp-remote, a small bridge that runs the HTTP transport for it over stdio. Add this to claude_desktop_config.json and restart the app:

{
  "mcpServers": {
    "kicklace": {
      "command": "npx",
      "args": [
        "-y",
        "mcp-remote",
        "https://www.kicklace.com/api/mcp",
        "--header",
        "Authorization: Bearer sk_live_…"
      ]
    }
  }
}

The file lives at ~/Library/Application Support/Claude/claude_desktop_config.json on macOS and %APPDATA%\Claude\claude_desktop_config.json on Windows. mcp-remote needs Node.js on the machine.

This is the bridge, for a client that still wants a key on disk. Claude Desktop's own connector list takes the address on its own and signs in — see below.

claude.ai, Claude Desktop's connectors, Claude on a phone

Settings → Connectors → Add custom connector, paste https://www.kicklace.com/api/mcp, and sign in when Kicklace asks. Nothing is copied: the connector registers itself, sends you to Kicklace's own sign-in, and you choose the workspace on the consent screen. It appears under Settings → Claude as a Connected app, and Disconnect there stops it at once.

Any other MCP client

Give it the URL https://www.kicklace.com/api/mcp and the transport Streamable HTTP. A client that speaks OAuth finds the sign-in by itself, from the WWW-Authenticate on the first 401; a client that does not takes the header Authorization: Bearer sk_live_….

When Kicklace adds a tool

Kicklace answers capabilities.tools.listChanged: false, and that is the truth rather than an omission: the door is stateless, there is no stream to push a notification down, and a client that reconnects loses nothing because there was never anything to lose. The one thing that refreshes a client is its own tools/list call — and clients differ in when they make it.

  • Claude Desktop and the phone apps read the list once, when the connector is added, and keep it. Switching the connector off and on in the Code tab's list of connected MCP servers makes them read it again, and that reaches a session already running.
  • claude.ai caches it with the connector. Remove the connector and add it again.
  • Claude Code and other key-based clients read it when they start, so restarting is enough.

(anthropics/claude-code#38324, closed "not planned", and #79491 are where this is written down.)

Because nothing on this side can force it, the door writes down what it handed over: every tools/list marks the key that asked, or every token of the OAuth grant that asked, with the moment and the tool names — never a schema, never a secret — and rotation carries the mark onto the new tokens, so an hourly refresh does not wipe it. Settings → Claude then says, under each Connected apps row and each key row:

  • behind — "Claude read the tool list 2 h ago and has 17 of the 62 tools. It does not know about find_records, describe_website, create_record and 10 more. In the Claude desktop app, switch this connector off and on in the Code tab's list of connected MCP servers; in claude.ai, remove the connector and add it again. A key-based client re-reads the list when it starts." (That is the real sentence for a client connected before the setup round: at most three tools are named, in the order tools/list gives them, and the rest are counted.) A name the client still holds that Kicklace has taken away is said too: "It still lists old_tool, which Kicklace no longer has."
  • current — "Has all 62 tools; read 2 h ago."
  • never listed — nothing at all. A key minted before this was written down has nothing to say, and a guess would be worse than silence.

The protocol, exactly

Everything is JSON-RPC 2.0 in the body of one POST. The answer is always application/json — never an SSE stream — and carries MCP-Protocol-Version. A body may hold one request object or an array of them; an array is answered with an array in the same order, minus the notifications.

initialize

{"jsonrpc":"2.0","id":1,"method":"initialize",
 "params":{"protocolVersion":"2025-06-18","capabilities":{},
           "clientInfo":{"name":"claude-code","version":"1.0"}}}
{"jsonrpc":"2.0","id":1,"result":{
  "protocolVersion":"2025-06-18",
  "capabilities":{"tools":{"listChanged":false}},
  "serverInfo":{"name":"kicklace","version":"0.1.0"},
  "instructions":"Kicklace is a small CRM for leads and consent-scoped email. You are connected to the workspace “WoodWright” and acting as Michael Fall, an owner there. Call describe_workspace first: …"
}}

The server answers 2025-06-18, or echoes 2025-03-26 when a client asks for it. The instructions paragraph names the workspace, the member the key acts as, and the rules above; a server key is told it may only read.

notifications/initialized

{"jsonrpc":"2.0","method":"notifications/initialized"}

Answered with HTTP 202 and an empty body, like any notification.

ping

{"jsonrpc":"2.0","id":2,"method":"ping"}{"jsonrpc":"2.0","id":2,"result":{}}

tools/list

{"jsonrpc":"2.0","id":3,"method":"tools/list"}
{"jsonrpc":"2.0","id":3,"result":{"tools":[
  {"name":"find_people",
   "description":"…what it does, when to use it, and what it will not do…",
   "inputSchema":{"type":"object","properties":{…},"additionalProperties":false},
   "annotations":{"readOnlyHint":true,"destructiveHint":false,"openWorldHint":false}}
]}}

readOnlyHint is the whole of it: a read tool changes nothing, a write tool does. Nothing here is destructive and nothing reaches the open web.

Every tools/list is written down on the key or the grant that asked, which is what lets Settings → Claude say whether a client's list is behind; see "When Kicklace adds a tool" above.

tools/call

{"jsonrpc":"2.0","id":4,"method":"tools/call",
 "params":{"name":"get_person","arguments":{"email":"greg@example.com"}}}
{"jsonrpc":"2.0","id":4,"result":{
  "content":[{"type":"text","text":"Greg Halloran · Subscriber · …"}],
  "structuredContent":{"id":"rec_…","name":"Greg Halloran","stage":"subscriber"}
}}

structuredContent is there only when the tool has data to give; content always is.

resources/list, prompts/list

Answered with {"resources":[]} and {"prompts":[]}. Kicklace offers tools and nothing else, but a client that probes should read "none", not "no such method".

The deadline

Every tool answers inside 25 seconds (TOOL_DEADLINE_MS in src/lib/mcp/contract.ts). A client gives a tool call about a minute and shows nothing at all until it ends, so a tool that runs past that is a tool nobody sees the end of — and the work is lost with it. Where a tool's work can run longer than that, it does not answer with a timeout: it hands back a draft or a link and one sentence saying how to finish, so the ask survives and the person has somewhere to go. propose_automation is the one that learned this, from five asks and five timeouts on 2026-09-08 (docs/audit/mcp-dogfood-2026-09-08.md).

No tool calls a model on the server except propose_automation's fallback, and that one is given twenty seconds — the deadline less the time to write the draft and answer — with one attempt and no repair call.

Writing an automation

propose_automation takes spec, name and description; at least one of spec and description. It always saves a draft, which never runs until a person activates it under Automations, and it never activates and never sends.

Write the spec yourself. You have already read the workspace's stages, lists, templates and members through describe_workspace, so you are the model: write the definition and Kicklace validates it against those identifiers in milliseconds, with no model call of its own.

{"name":"Waitlist: welcome",
 "spec":{
   "when":{"kind":"subscribed","list":"macos-waitlist"},
   "wait":{"days":2,"cancelIf":["reply","unsubscribes"]},
   "onlyIf":[{"kind":"not_sent_before"}],
   "then":[{"kind":"send_email","template":"Welcome to the macOS waitlist","list":"macos-waitlist"}]}}

The spec is the runnable half only. Kicklace writes the sentences a person reads from it — the same wording the app's own by-hand editor writes — so what the automation's card says and what it runs can never disagree.

Part What it takes
when (one) stage_entered {stage, object?} · event {event} · subscribed {list} · unsubscribed {list} · field_changed {field, object?} · schedule {every, weekday?, day?, at, object, view} · webhook {}
wait {days, cancelIf?, until?}stage_changes, reply, unsubscribes; until {weekday?, at}. Left out, it runs at once
onlyIf in_stage {stage} · not_in_stage {stage} · subscribed {list} · not_sent_before · field_equals {field, value} · field_set {field} · ai_decide {question}
then send_email {template, list} or {template, transactional: true} · notify {member, message} · move_stage {stage} · set_field {field, value} · set_fields {values} · create_task {title, assignee?, dueInDays?} · create_record {object, name, fields?, link?} · post_slack {message} · http_request {url} · draft_broadcast {list, template} · draft_email {template, assignee?} · ai_classify {field, question?} · ai_summarize {instruction?} · ai_extract {fields}
otherwise The same shapes as then. What runs instead when a condition does not hold; left out, the run is skipped. One level

docs/automations-v2.md is the whole language in one place — every part, its sentence, its rule, and which of them the engine runs today. Read it before writing a spec that uses anything below the first four rows above.

Every part of the language runs (#214, finished 2026-09-09): the schedule and webhook triggers, a wait's until and otherwise (B), the five actions that reach out — set_fields, create_record, post_slack, http_request, draft_email (C) — and the four AI steps ai_decide, ai_classify, ai_summarize, ai_extract (D). The worker starts a schedule at its own hour, and every automation with a webhook trigger has an address printed on its own page in the app for the customer's server to post to. A part from a release ahead of this one still comes back as Not runnable yet: … among the problems, with the draft saved anyway; say so when you propose one, rather than leaving somebody to find out at the Activate button.

schedule is how "every Monday at 9" is written: every is day, week (with weekday, 0 Sunday to 6 Saturday) or month (with day, 1 to 28), at is HH:MM in the workspace's own timezone, and object + view name the saved view whose records it runs for — describe_workspace prints every view with its key. ai_decide and the three ai_* actions each cost one model call against the workspace's daily allowance when they run; ai_extract fills only blanks and never a select field, and ai_classify may only write one of that field's own options. Where AI is switched off or the day's allowance is spent, an AI action is skipped with the reason in the run's log and the rest of the run carries on, and an ai_decide that cannot be answered skips the run: a question with no answer is not a "no", so neither branch is taken. draft_email is draft_broadcast's one-to-one twin: it writes the words and leaves a task, and nothing here ever sends.

event is one of Kicklace's own — visit, form, download, activated, signup, purchase, email_in, email_out, support, call, meeting, note — or a custom name this workspace's own servers have posted. not_sent_before is how "only once per person" is written. A send_email names the list the person must be subscribed to, because Kicklace never emails somebody who did not opt in; the one exception is transactional: true with no list, allowed only when the trigger is the person's own action (a purchase, a sign-up, a support message, a reply, a custom event, or a post to this automation's own webhook address). A schedule is never the person's own action — the clock is nobody's doing — so a send on one always names a list. member and assignee are a member's name or "owner".

draft_broadcast {list, template} is the action for something that is not about one person — a deployment, a release, a milestone — that everyone on a list should hear about. It writes an email to that list, copies the template's words into it, leaves it in the composer and tells the workspace's owners and admins that a draft is waiting. It never sends and it never schedules, which is the same rule the draft_broadcast tool keeps: an automation you propose can do no more here than you can do by hand. So "when production deploys, draft the changelog email" is an ordinary automation — a stage_entered trigger on the Releases pipeline the Vercel receiver fills, and one draft_broadcast in then.

Identifiers are the ones describe_workspace prints: stage keys, list slugs, field keys, template names, member names, object slugs, saved view keys. A name typed the way a person types it ("the waitlist", "Marcus", "the people still waiting") is matched to the one entry it clearly means before anything is judged.

What comes back. The link, one sentence saying what it does, and — when something in it will not run — It cannot be activated yet: … with the problems. The draft is saved anyway, exactly as the app saves a draft with problems, so a person can finish it in the editor. The one refusal is a spec with no trigger at all (no_trigger): there would be nothing to finish, only a name.

Or send a description — a plain sentence — and Kicklace's own model writes it instead. That path is slower, costs a call against the workspace's daily AI allowance, and is refused in one sentence when AI is off, switched off for the workspace, or out of allowance. If the model does not answer inside its twenty seconds, the answer is still a saved draft: it holds the ask and nothing that runs, with the link and the two ways to finish it.

When it refuses

Two kinds of refusal, and they mean different things.

A refusal the model should read and act on comes back as a normal result with isError:

{"jsonrpc":"2.0","id":5,"result":{
  "content":[{"type":"text","text":"There is no list called newsletter; the lists are macOS waitlist, Product updates."}],
  "isError":true}}

Every one of these is one sentence with the fix inside it. An unknown tool name, a write under a server key, and anything a tool itself refuses all arrive this way. A server error arrives as "Kicklace could not do that; the error is logged." and nothing more — the stack goes to the deployment's logs with the usual [kicklace error] line.

A broken request comes back as a JSON-RPC error:

Code When
-32700 The body is not JSON (HTTP 400)
-32600 The body is not a JSON-RPC request object, or an empty array (HTTP 400)
-32601 No such method
-32602 tools/call with no params.name, or arguments that are not an object

And a refusal at the door is an HTTP status with { "error": …, "message": … }:

Status error When
400 use_the_public_key_from_browsers The request carried an Origin header and a secret key
400 unsupported_protocol_version An MCP-Protocol-Version header this server does not speak
401 invalid_key No bearer token, or a key Kicklace does not know
401 invalid_token An access token that has expired, been disconnected, or was issued for another resource
403 no_longer_a_member The member behind the key or the token has left the workspace
405 post_only GET or DELETE — there is no stream to open and no session to end
413 too_large A body over 64 KB
429 rate_limited More than 600 calls from one key or token in ten minutes

Every 401 carries the challenge that says where to sign in: WWW-Authenticate: Bearer realm="kicklace", resource_metadata="…/.well-known/oauth-protected-resource", with error="invalid_token" in it when a token was sent and refused.

What is written down

Every tool call is one row in agent_calls: when, which key, which member, the tool, whether it read or wrote, whether it worked, the outcome word, how long it took, and the tool's own one-line summary ("Added a note on Greg Halloran"). No arguments are stored — no address, no message body, no note. Thirty days are kept; the worker prunes the rest on every check. Settings → Claude shows the last twenty rows and a 30-day count.

Records that are not people (#187)

find_people and create_person know one object. Everything else a workspace keeps — organizations, deals, and any object it added under Settings → Objects and fields — goes through these two. Both take an object by its slug from describe_workspace (organizations, deals, workshops), and both also answer to the object's name or plural, because that is how a model that has read the workspace tends to write it.

Nothing else changed: get_person, update_person, move_stage and add_note already take any record by id, whatever kind it is, so a row found here is opened and changed with the tools that were already there. The id is the last segment of the link on every row.

find_records

Reads. Takes object (required), then q, stage, view, organization, days, sort (activity · created · name), limit (up to 50, 20 by default) and page. stage is a stage key or its name; a key is unique across an object's pipelines and a name may not be, and a name two pipelines share is sent back for the key rather than guessed between. organization takes a name, a domain or an id and answers with the records linked to it, which can never be wider than that organization holds — so it and view are not asked for together.

Each row is one line: the name, the stage (with the pipeline's name when the object has more than one), the fields a person reads first, when it was last active, and the link.

2 deals. Showing 1–2, newest activity first.
Acme fit-out · Talking on Sales · Amount: $5,000 · Expected close: Sep 30, 2026 · Owner: Marcus · last active Sep 8, 2026 · https://…/directory/deals/rec_…
Ridge shop fit · Quoted on Sales · Amount: $900 · last active Sep 8, 2026 · https://…/directory/deals/rec_…

The fields it picks are an organization's domain and city; a deal's amount, expected close, owner, and close reason once it sits in a won or lost stage; and, for anything the workspace added itself, that object's own first two fields, which is what its directory shows.

create_record

Writes, and any member may. Takes object and name (both required), fields (that object's own keys, cleaned the way the app's form cleans them), stage, and organization.

  • An organization whose name is already here exactly is handed back rather than written a second time — the rule the app's own CSV import keeps. A deal or a custom record is always new, because two of those can honestly share a name.
  • A record whose object has a pipeline starts in that pipeline's first stage unless stage names another, in any of the object's pipelines. Whatever automation listens for that stage hears it, exactly as it does when a member fills in the form.
  • organization links the two with the app's own label: works at from a person, for from a deal, partner of between two organizations.
  • The row is written in your name, on the timeline as "<member> via Claude".
Added the deal Harbor kitchen run in Talking, for Harbor Millwork. https://…/directory/deals/rec_…

The refusals

error When
wrong_tool object named the People object — find_people and create_person know them
deals_are_off Deals are switched off for this workspace (Settings → General)
unknown_object No object here goes by that slug, name or plural; the answer lists them
unknown_stage No such stage, or a name two of the object's pipelines share
unknown_view No saved view of that object by that key or name
unknown_field A key that object does not have; the answer lists its keys
unknown_record No organization matches — find it with search, or add it with create_record
ambiguous_record More than one organization matches; say which by its id
two_ways organization and view in one call
bad_argument A missing object or name, a value a field will not take, name inside fields
unknown_argument An argument this tool does not take; the answer lists the ones it does

Setting the workspace up: stages, pipelines and fields

Three tools shape a workspace by adding to it. Each is a write, so it needs a key or a connector a person signed in with, and each is for owners and admins, the same rule Settings → Pipelines and Settings → Objects and fields apply. None of these three renames, reorders or removes anything — that is Changing what is already there below — and each tool's answer links to the screen it belongs to.

A key never changes. A stage's key comes from its name (“Demo booked” → demo-booked), a field's from its label in camelCase (“Close date” → closeDate), and from then on that is what saved views, chart specs, automations, email variables, run_report and move_stage refer to it by — even after the stage or the field is renamed. So every answer here prints the object back the way describe_workspace prints one, with the new key in it, and the model does not need a second call to write the automation that uses it.

add_stage

Adds one stage to a pipeline.

Argument
object Required. An object's slug, name or plural, as describe_workspace prints it.
pipeline The pipeline's name. The object's first — its default — when left out.
name Required. 1 to 40 characters.
after The key or name of the stage this one goes behind. Last when left out.

It answers with the new key, the object's whole structure, and the link to Settings → Pipelines. Adding a stage to a person's lifecycle is allowed, exactly as it is on that screen; a lifecycle stage is reached by what somebody did rather than by hand, so a step your team performs usually belongs on a pipeline of its own.

Refusals: unknown_object (with the slugs the workspace has), deals_are_off, no_pipeline (make one with create_pipeline), unknown_pipeline, unknown_stage for an after nothing matches, not_allowed for a member, and stage_exists — a name whose key that object already holds, anywhere in any of its pipelines, because stage keys are unique across an object's pipelines. The app would number that key contacted-2; the door refuses instead, so a key stays something a person can predict.

create_pipeline

Makes another pipeline for an object, with its stages in order.

Argument
object Required.
name Required. 2 to 40 characters, and different from the object's other pipelines.
stages Required. The stage names in order, 1 to 12 of them: ["Joined", "Contacted", "Invited", "Live"].

An object may have several pipelines and a record sits in one of them at a time, so moving a record to a stage of another pipeline takes it across. A person's first pipeline is always the lifecycle and stays where it is; anything made here sits beside it. Here a stage name whose key is taken is numbered (invited-2) rather than refused, as it is on the screen — the answer prints every key it got, so read it before writing an automation against one.

Refusals: unknown_object, deals_are_off, not_allowed, bad_argument for no stages or more than twelve, and the app's own sentences for a name too short (“Give the pipeline a name of 2 to 40 characters.”) or one the object already has (“People already have a pipeline called Waitlist.”).

add_field

Adds one field to an object.

Argument
object Required.
label Required. What it is called on screen, 1 to 40 characters. The key is made from it.
type text, email, phone, url, number, money, date or select. Text when left out.
options For a select only: two or more values, none containing a comma.

It answers with the key, the object's structure, and the link to Settings → Objects and fields. The key is what update_person writes to, what a saved view or a chart spec filters on, and what {{closeDate}} resolves to in an email.

Refusals: unknown_object, deals_are_off, not_allowed, bad_argument for a type that does not exist, for options on a field that takes none, and for an option with a comma in it; the app's own “A select field needs at least two options, separated by commas.”; and field_exists — a label whose key the object already holds, rather than a second phone2.

Keeping a report (#191)

run_report answered and kept nothing, so "all web traffic monitored" was a question asked again every morning rather than a dashboard somebody opens. Two tools keep an answer. Both only add: neither removes a chart, rearranges a dashboard, makes a dashboard, renames a view, or touches a record — those are in Changing what is already there below — and neither spends anything, because no model wrote either of them, so nothing lands on the AI meter. describe_workspace prints the identifiers they take: every object's saved views with their keys, and every dashboard with how many charts it holds.

save_report

Puts the spec run_report just ran on a dashboard as a chart. The chart is live: it counts again every time somebody opens the dashboard, so what lands is a standing question and not today's number.

Takes
spec Required. The same object run_report takes — {title, kind, object, metric, filters, groupBy?, dateField?, compare?, buckets?, higherIsBetter?}. run_report's own description is where the grammar is written down, so there is one copy of it.
title What the dashboard calls the chart, 2 to 6 words. The spec's own title when left out.
dashboard Which dashboard, by name. The first one — usually "Main" — when left out.

Answers with the chart's title, the dashboard it landed on, what it shows right now (the same lines run_report prints), and the dashboard's link. structuredContent carries the chart's key, the dashboard, the URL, and the figures.

{"name":"save_report","arguments":{
  "spec":{"title":"Visits by week","kind":"line","object":"events",
          "metric":{"fn":"count"},"filters":[{"by":"eventType","value":"visit"}],
          "groupBy":{"by":"week"}},
  "dashboard":"Main"}}

"Visits by week" is on the Main dashboard now. It counts again every time the dashboard is opened.

Refusals: bad_spec for a spec that is not one, a chart with no name, or a spec that will not run — with the same problems run_report would list, because a report not worth running is not worth a place on a dashboard; unknown_dashboard names the dashboards that exist and says a dashboard is made in the app, under Edit dashboard.

create_view

Saves a filter over one object as a view, so a question about a segment becomes a place in Contacts rather than a search somebody repeats. The view sits in the Views rail with its own live count, find_people takes it by key, and the whole workspace sees it — a view belongs to the workspace, not to one person.

Takes
object Required. An object slug from describe_workspace.
name Required. What the view is called, 2 to 60 characters.
filter {stage?, stages?, list?, open?, withinDays?, fields?}. Every part applies together; an empty filter is every record of that object.
description One line saying what it is for, up to 200 characters.

stage is one stage key and stages is several, any of which matches. list is a list slug and only people currently on it. open leaves out the stages that end a pipeline. withinDays counts back from the day the record was made. fields is a list of {key, op, value?} where op is is, contains, set or empty — the first two need a value, the last two do not.

{"name":"create_view","arguments":{
  "object":"people","name":"On the waitlist",
  "filter":{"stage":"subscriber","fields":[{"key":"email","op":"set"}]}}}

Saved the view "On the waitlist" of people. Its key is on-the-waitlist, and it matches 42 people right now.

Refusals, each naming what does exist: unknown_object, unknown_stage, unknown_list, unknown_field, not_people (only people are on a list), unknown_argument for a part of the filter that is not one, and bad_argument for an op that is not one, an is or contains with nothing to match, or a name outside 2 to 60 characters. The view builder in the app quietly drops a condition that names something removed, which is right for a form somebody is looking at and wrong for a tool: a model that spells a stage wrong would otherwise get a view that means something else and never hear about it.

The website hookup (#192)

Two tools for the question a founder asks first: is the site connected? They are the door's half of Settings → Your website, and they say what that screen says, in the same words.

describe_website (read)

Takes no arguments. Answers with the state in one sentence, then the hookup it is made of.

The first line is one of three:

  • Connected: the tag reported 4 min ago, from northshelf.com.
  • `Nothing has arrived yet; the tag is <script async src="…/track.js"></script> and it goes in the<head> of every page.` — with `This workspace names no site either…` after it when no address has been added, because a post from a browser would be refused anyway.
  • The tag is working — posts are arriving — but every one of them is being refused.

Where a post has been turned away from an address this workspace does not allow, that sentence carries Posts are being refused from staging.northshelf.com; allow it with allow_site. — whether the site is otherwise connected or not, since it is the one thing on this screen a tool can fix.

Then four sections:

Section What is in it
SITES ALLOWED The addresses a browser may post from, an apex and its www twin as one row, and what happens to anything else (origin_not_allowed)
THE TAG The one script tag, what it records, and where it goes on Webflow, Framer, WordPress, Squarespace, Carrd and a plain HTML or Next.js site — the same six notes as the setup prompt and docs/site-platforms.md
SIGNUPS Whether only forms marked data-kl-list="<slug>" are read or every form with an email box is, which list they land on, the lists a form may name, and the rule that a form with no consent words puts nobody on a list
HEARTBEAT When the last post arrived, how the last seven days went, and the week's refusals as the sentences the screen shows ("staging.northshelf.com is not one of your sites", "No consent line came with this signup") — never a raw code alone

structuredContent carries the same facts: connected, arrived, lastPostAt, sites, tag, ingestBaseUrl, capture, lists, heartbeat (with problems and refusedSite), settingsUrl.

The tag it prints carries the workspace's public key, which is public by design: it is in every page that carries the tag and it can only add people, never read. No secret key ever appears in an answer, and none belongs in a browser.

allow_site (write, owners and admins)

Argument
site The website's address, however it is written

northshelf.com, https://northshelf.com/pricing and HTTPS://NorthShelf.com all reduce to https://northshelf.com; the www twin is stored beside it, because an apex and its www are one website to whoever owns it. *.vercel.app names every subdomain. Connecting a site that is already connected writes nothing and says so.

The answer is the pair now allowed, every site the workspace allows, and the state sentence again — so one turn says whether the fix landed — with `Nothing else changed. The tag still has to be in the

<head> of the site; describe_website prints it.`
Refusal When
bad_address Not an http or https address. "That is not a website address. Paste it the way you say it, like northshelf.com."
bad_argument site missing or empty
not_allowed A member rather than an owner or admin
unknown_argument Anything but site

This is the one tool that changes a setting, and it changes exactly the one its name says: the list of addresses a browser may post from. It does not put the tag on the site — a person, or their own AI with the prompt from that screen, does that — and it touches no key.

Templates and lists (#188, #189)

The four tools that set up the sending half of a workspace: the words an email says, and the thing that makes saying them to somebody allowed. None of them sends anything and none of them deletes anything, and all four are for owners and admins, exactly as the screens are.

create_template, update_template

An email template is copy waiting for an automation to send it, or for a person to start a broadcast from it. Write one before asking propose_automation for an automation that sends an email, because an automation may only name a template that already exists.

create_template update_template
Takes name, subject, body, description? template (name or id), then any of name, subject, body, description
Answers the subject, the email as the person will read it, the variables in it, the link the same, plus what changed

The body is plain text with a little markup — a blank line starts a paragraph, - is a bullet, # is a heading, **bold** is bold, [label](https://…) is a link, a link alone on its own line is a button, --- alone is a rule — and the answer prints textToPlain's rendering of it back, so what the model reads is what the person receives.

Variables are {{like_this}}: {{first_name}}, {{name}} and {{email}} work everywhere, any Person field key works ({{phone}}, and whatever the workspace has added), and {{organization}} is the organization they are linked to. A variable nothing can fill is sent empty rather than as braces, so the answer names any the workspace has nothing behind: "Nothing here fills {{signup_link}}, so it comes out empty."

Renaming is safe, and only here. Automations name the template they send, so updateTemplate rewrites every automation that names it in the same write, and the answer says how many: "It was called “The Mac build”; 1 automation that sends it was rewritten to the new name." Where the name did not change but the words did, the answer names who sends it instead.

A template Claude writes is written by "<your name> via Claude", which is what the template page prints under its name.

Refusals: not_allowed (a member), unknown_argument, bad_argument (a missing or overlong name, subject, body or description; an update that changes nothing; a body emptied rather than left out), unknown_template — which names the templates that are here.

delete_template does not delete one: it is a proposal, and the answer names the active automations that would be paused when it goes. A template can also be removed in the app, under Automations → Templates.

create_list, update_list

A list is what makes emailing somebody allowed, and its consent sentence is the proof: it is stored word for word on every subscription made against the list, printed beside the tick box on a form Kicklace hosts, and cited in the footer of every email that goes out.

create_list update_list
Takes name, consent_text, description? list (slug or name), then any of name, consent_text, description
Answers the address, the sentence, both ways to sign people up, the link what changed, the sentence now, the link

consent_text is 10 to 300 characters: the sentence the person will read and agree to, in their own terms and in the first person — "Email me when the macOS build is ready.", "Send me product updates about WoodWright. I can unsubscribe at any time." — not a label like "newsletter" and not a description of the list. It is stored exactly as it was written.

The list's address is made from its name and never changes, because a website is posting to it, so neither tool takes a slug: create_list derives one ("Product updates" → product-updates), numbers it when another list already has it, and refuses a name that makes nothing to build an address out of. The answer hands back the next step both ways:

Your own signup form, one attribute on the form tag: <form data-kl-list="product-updates">
A form Kicklace hosts, for a builder that will not take the attribute:
https://www.kicklace.com/f/<workspace>/product-updates

Rewording a list changes what the next person agrees to and nothing about anybody already on it. Their own consent records are the proof of what they agreed to on the day; no tool and no screen rewrites those, and update_list says so in its answer.

Refusals: not_allowed (a member), unknown_argument, bad_consent (a sentence outside 10 to 300 characters — nothing is written), bad_name (a name that makes no address, or a hundred lists already numbered off it), list_exists, unknown_list — which names the lists that are here — and bad_argument for an update that changes nothing.

Neither tool puts anybody on a list: that is add_to_list, one person at a time, with the words that person agreed to. And there is no delete_list — a list anyone has ever joined holds their consent records and stays, which is a rule of the app and not of the door.

Changing what is already there (#210)

Everything above adds. Twelve tools change what a workspace already has — an automation, a stage, a field, an object, a chart, a dashboard, a view — because a founder's Claude that can set a CRM up on Monday and cannot adjust it on Tuesday is only half a door.

All twelve are writes for owners and admins, and all twelve go through the app's own data layer, so the rules are the screens' own and a problem a person can fix arrives as the sentence that screen itself shows. Two things do not move:

  • A key never changes. Renaming a stage, a field or an object changes only the words a person reads: contacted stays contacted, {{phone}} stays {{phone}}, and an object renamed from Releases to Shipments is still the slug releases. So nothing that names one has to be rewritten, and a rename is never a way to repurpose something.
  • Sending, destroying and pasting a secret stay behind a person. Nothing here deletes a record, a list, a template or an object, and nothing here sends. The two that take something away take a stage — whose records move to a stage you name — and a chart, which is a saved question and not a record.

Automations

Takes Does
update_automation automation, then any of spec, name, description Rewrites a draft or paused automation
activate_automation automation Switches it on, or proposes it
pause_automation automation Stops it matching new records

automation is its number (3 or #3), its name, or its id.

spec is propose_automation's language exactlywhen, wait, onlyIf, then and the optional otherwise branch — and it is the whole definition: what you send replaces what is there, the branch included. It goes through the same resolve → complete → validate path, and Kicklace composes the sentences a person reads from the runnable parts, so an automation Claude edits reads on its page like one the app wrote and the card can never disagree with what runs. Anything it cannot run comes back as a problem and the definition is saved anyway, exactly as the app saves one. description is why it changed, in plain words, and is kept on the automation's transcript beside the app's own hand edits.

An active automation is refused (automation_is_active): pause it, change it, activate it again. Nothing should change under a definition that is firing.

Activating an automation that sends email is a proposal, not a change. When the definition has a send_email in it, activate_automation writes a pending approval whose summary is the app's own activation question — the two sentences the Activate button asks, composed from the runnable parts — and answers:

“Waitlist: the Mac build” sends email, so switching it on is a proposal rather than a
change: it is still a draft and nothing runs until somebody approves it.

Waiting for your approval: https://www.kicklace.com/app/<workspace>/approvals/apr_…

From now on, when a person subscribes to macOS waitlist, Kicklace sends them The Mac
build.
Nobody is on macOS waitlist today; nothing is sent for what already happened.

The automation is left exactly as it was. Everything else — a notification, a stage move, a field, a task, a drafted broadcast — is switched on there and then, and the answer is the same two sentences, already true, with the number of held runs that will go at the next check. A definition with anything left to fix is refused (not_ready) with the problems named, because a definition that cannot run is not switched on quietly.

A pause holds the runs waiting behind it rather than cancelling them: each goes when the automation is activated again, and the answer says how many.

Refusals: not_allowed, unknown_automation (naming the ones that are here), ambiguous_automation, automation_is_active, not_ready, no_trigger, bad_argument. An automation already active, or already paused, is answered rather than refused: nothing changed.

Stages, fields and objects

Takes Does
rename_stage object, stage, name Changes what a stage is called; keeps its key, colour, description and won-or-lost mark
reorder_stages object, pipeline?, order Puts one pipeline's stages in the order given
remove_stage object, stage, move_to Takes a stage off and moves its records to the stage named
rename_field object, field, label Changes a field's label; keeps its key, type and options
rename_object object, name, plural?, description? Renames a custom object; keeps its slug

reorder_stages wants every stage of that pipeline exactly once, by key: a partial order is refused rather than guessed at, so a stale reading of the workspace cannot quietly drop a column. The order is what the board's columns read left to right and what a funnel counts down, so a stage's place is also its rank.

remove_stage's move_to is not optional: no record is ever left without a stage here, and each one that moves gets a line on its own timeline saying the stage was removed, exactly as Settings → Pipelines does it. Beyond what the app refuses (a pipeline's last remaining stage) it refuses two more things by name: the first stage of a person's lifecycle, where everybody the website sees arrives, and a stage an active automation names — the automation is named, and pausing or changing it comes first. Saved views that ask for the stage are named in the answer, because they will show nothing until somebody edits them.

rename_object is for an object this workspace made. Built-in objects keep their names: Person, Organization and Deal are what the ingest route, the lifecycle and the consent rules are written against, and deals are switched off, not renamed, under Settings → General. plural left out keeps the plural it already has, because that is what the tabs and the page titles say.

Every one of the five prints the object back the way describe_workspace prints one, so the model has the structure it now has without a second call.

Refusals: not_allowed, unknown_object, deals_are_off, unknown_stage, ambiguous_stage (a name two pipelines both use — say the key), unknown_field, built_in_object, lifecycle_first_stage, stage_in_use, bad_argument. A rename to the name something already has is answered, not refused: nothing changed.

Dashboards and views

Takes Does
remove_chart chart, dashboard? Takes one chart off one dashboard
resize_chart chart, w, h, dashboard? Changes how much room a chart takes
create_dashboard name Makes another dashboard, empty
rename_view object, view, name, description? Renames a saved view; keeps its key and its filter

chart is the title the dashboard shows, or the chart's key; dashboard is a name, and the first one — usually "Main" — when left out. Two charts under one title are refused (ambiguous_chart) with both keys named, because removing or resizing the wrong one is not something an answer can take back. Removing a chart changes nothing about the records it counted: a chart is a saved question. One the workspace started with can be put back under Edit dashboard → Put a chart back, and the answer says when the one removed was one of those; one saved from a report comes back through save_report.

w is 3 to 12 of twelve columns and h is 1 to 4 rows, the units the app drags in — a number tile is happy at 1, a bar or line chart at 2, a table wants 3 — and everything else on the dashboard keeps the order and the size it had. A size outside the grid is refused rather than silently clamped.

create_dashboard makes an empty one; save_report then puts a chart on it by name. A name another dashboard already has is refused rather than made twice, and no chart moves — charts are dragged between dashboards in the app.

rename_view changes the words and nothing about what the view asks for; re-filtering one is Contacts → Edit view, and a different question is a new view from create_view. The view a directory shows before anybody has saved one — the "All …" one — is refused by name (view_not_saved): there is nothing stored to rename.

Refusals: not_allowed, unknown_dashboard, unknown_chart, ambiguous_chart, unknown_object, unknown_view, view_not_saved, bad_argument (a size outside the grid, a name a dashboard already has, a name too short or too long). A chart already that size, or a view already called that, is answered: nothing changed.

The workspace itself (#210)

The four screens a founder opens on their first morning, as eight tools: Settings → General, Settings → Members, a list in bulk, and Contacts. Everything here but export_record is for owners and admins, exactly as those screens are.

Where the door and the app differ, the door is the narrower of the two. It will not move the workspace's address (its slug), because every link anybody has saved points at it. It will not touch the AI switch, which is the workspace's own half of the leash on the model and belongs to a person. It will not hand out the owner role, change an owner's role, or change your own. And export_record answers a link rather than a file.

set_timezone

Takes timezone — an IANA name
Answers what it is now, what it was, and that nothing already recorded moved

Every chart on every dashboard counts its days, weeks and months in the workspace's timezone, and a workspace that has never set one counts them in UTC. The name is taken the way a model writes it — europe/london and New York are both understood, the first as Europe/London and the second by naming what it could have meant — and it is spelled back the way the IANA database spells it. Nothing else about the workspace changes, which is also why a workspace whose own address is one of Kicklace's reserved words can still set a timezone.

Refusals: not_allowed, unknown_argument, bad_argument, unknown_timezone — which names up to five near misses.

set_workspace

Takes any of name, deals (true/false), retention_days
Answers what changed, in the words Settings → General uses

retention_days is how long an anonymous visitor who never left an address is kept before the nightly job deletes them, and it is one of 0, 30, 90, 180, 365 — 0 keeps them until somebody deletes them. Switching deals off hides the Deal object, the Sales pipeline and every deal chart and deletes nothing. Anything left out stays as it is, and a call that would change nothing says so rather than pretending.

There is no slug argument and no ai argument: the first is refused as unknown_argument with the sentence that says the app moves an address, and the second is not the door's to touch.

Refusals: not_allowed, unknown_argument, bad_argument (nothing to change; a retention period nothing offers; a name outside 2 to 60 characters).

invite_member, change_role

invite_member change_role
Takes email, role (member | admin) member (name, address or member id), role
Answers the invitation link, its expiry, whether the email went out what they are now and what that means

An admin can do everything an owner can except hand out the owner role and delete the workspace; a member can see everything and change records, and cannot change settings, send email or manage membership. role: "owner" is refused by both with the sentence that says an owner hands that out in the app, and change_role will not touch somebody who is already an owner — that is also what keeps a workspace from losing its last one — or your own membership, because from the other side of that change you could not change it back.

invite_member opens the invitation and sends the same email Settings → Members sends; the link is in the answer either way, so a deployment with no email provider is still usable. An address that is already a member is refused by name. An address that already has an open invitation is refused too, and the answer hands back that invitation's link rather than writing a second one. Neither tool removes anybody: that is done under Settings → Members.

Refusals: not_allowed (a member, a server key, or an owner-shaped request), unknown_argument, bad_argument, already_a_member, already_invited, unknown_member — which names the members — and ambiguous_member.

add_many_to_list, remove_many_from_list

add_many_to_list remove_many_from_list
Takes list, people — up to 200 { person, consent_text } list, people — up to 200 references
Answers added, already on, and every one left alone by name removed, not on it, and every one left alone

These are add_to_list and remove_from_list over a batch, and they keep every one of those rules. Each person needs their own consent_text, 10 to 500 characters — a stack of cards from a stand and a reply that said yes are two different places, and the row that proves it says which — and it is stored word for word as that person's consent record. A sentence that is not one refuses the whole batch, naming its position, before anything at all is written, so a batch is never half-consented. A person already on the list keeps the consent they first gave.

A person is an email address, a record id, or a name as it is spelled here. On the way in, an address nobody here holds adds them, exactly as a signup from the website would; somebody here with no address at all is skipped and named, never given one. On the way out, an address nobody holds is named rather than invented, consent records stay as the proof they once agreed, and every removal lands on that person's own timeline.

Both stop and say where they stopped if a batch of two hundred would run past the door's deadline ("This call stopped after 140 entries to answer inside its time; send the remaining 60 in another call. Everything above is written."). Nothing is sent by either.

Refusals: not_allowed, unknown_argument, bad_argument, bad_consent, too_many, unknown_list.

export_record

Takes record — an id, an email address, or a name; any object
Answers the link to the app's own JSON export

Everything Kicklace holds about one record: its fields, its identities, its subscriptions with the words behind each one, its whole timeline, every email sent to it, its links, and anything merged into it. It is the answer to "what do you have on me", and it is the app's own export, so opening the link needs somebody signed in as a member of the workspace.

It hands back the link and not the file on purpose: a person's whole history is theirs, and it is not printed into a conversation because somebody asked for it in passing. get_person answers with the part you actually need. It is the one tool here any member may call, and the one that is a read.

Refusals: unknown_argument, bad_argument, unknown_record, ambiguous_record.

import_rows

Takes object, rows — up to 200 objects keyed by field key
Answers created, updated, and every row that did not land with its own reason

Contacts → Import, for rows the model already holds. The rule is fill blanks, never overwrite: a person is matched by email address and every other object by name, and a row's values only land in fields that are empty, so an import can never quietly replace what somebody typed. A stage is applied only to a record that has none, because a stage is what happened to somebody and not a column. A row that cannot be brought in names itself and stops nothing.

A key the object has no field for is refused by name rather than dropped — the import screen's mapping step would quietly ignore it, which is right for a form somebody is looking at and wrong for a tool — and the refusal lists the keys that would work.

It fires no automations. A spreadsheet arriving is not a hundred people signing up. It also puts nobody on a list: that needs the words each of them agreed to, which is add_to_list or add_many_to_list.

Refusals: not_allowed, unknown_argument, bad_argument, too_many, unknown_object, deals_are_off, unknown_field. that person agreed to. delete_list does not remove one either — it is a proposal, and a list anyone has ever joined holds their consent records and is refused before it is even proposed, which is a rule of the app and not of the door.

Proposals (#211)

Three kinds of act leave the building: sending an email, destroying something, and cutting loose a key or a door somebody's server is posting to. A sent email cannot be recalled, a deleted record cannot be restored, and a revoked key breaks a running system. So Kicklace does not refuse those and it does not do them: a tool writes down what it wants, in one sentence, and an owner or admin approves it with one click.

Everything else Claude does directly. Where other CRMs pause every write, Kicklace pauses only what cannot be undone.

The rule

A proposal tool never performs the act. A call writes one approvals row and answers with its link, and that is all that happens on the call. Approving it runs the act through the app's own data layer, in the approving member's name, and anything it lands on a timeline says so:

Michael Fall, proposed by Claude

A proposal expires after seven days; the worker marks it and nothing runs. Rejecting one runs nothing, ever.

The tools

Tool Proposes
send_broadcast sending a draft to everyone on its list
send_email one email to one person who has engaged
delete_record deleting one record and everything on it
delete_list removing a list nobody has ever joined
delete_template deleting a template, pausing the automations that send it
delete_object deleting an empty custom object
remove_field removing a field and clearing its value everywhere
remove_member removing somebody from the workspace
revoke_key revoking one secret key
disconnect_integration disconnecting Stripe, Clerk, Kinde, Vercel or Slack
merge_records folding one record into another

activate_automation also becomes a proposal when the automation it names sends an email.

All eleven are for owners and admins. list_approvals (a read) is what shows the state: what is waiting, and the last few that were decided, each with its id, its sentence, who asked, and what happened when it ran.

Every one of them carries destructiveHint: false in tools/list, which is the plain truth: nothing destructive happens on the call.

The answer

Waiting for your approval: https://www.kicklace.com/app/northshelf/approvals/apr_7f3k2p9x.
Send “Still on the waitlist” to 33 people on Waitlist. It expires on Sep 15, 2026.

The email goes out to everyone on that list at that moment, from this workspace's own
sender, with the list's consent line and an unsubscribe link in the footer. It cannot be
taken back.

Nothing has happened yet, and nothing will until an owner or an admin approves it in
Kicklace. It is on their Approvals page and in their notifications now.

structuredContent carries { proposed: true, approval, kind, status: "pending", summary, url, expiresAt }.

What a person sees

Every owner and admin gets one notification — "Claude wants to send “Still on the waitlist” to 33 people on Waitlist" — with the link, and the workspace's Slack channel gets it too if one is connected. The link lands on Approvals in the sidebar, which counts what is waiting:

  • the sentence, who asked and when, and when it runs out;
  • Approve and Reject, for owners and admins — a send or a delete asks a second time, because neither can be taken back;
  • what approving it does, in the same words this document uses;
  • afterwards, what happened — "Sent to 33 people." — or the sentence the app refused it with.

An approval that could not run keeps its refusal on the row rather than throwing it at anybody: "That person has not been in touch, so Kicklace will not email them." Nothing runs twice: the row is claimed with one UPDATE … WHERE status = 'pending' RETURNING, so two people pressing Approve in the same second send one email.

Refusals

not_allowed (a member who is not an owner or admin), unknown_argument, bad_argument, unknown_record / ambiguous_record, unknown_list, unknown_template, unknown_object, unknown_field, unknown_member, unknown_key, unknown_broadcast, unknown_provider, and the ones that say a proposal could never succeed and so is not written at all: not_engaged, list_in_use, core_field, built_in_object, last_owner, public_key, not_connected, already_sent, same_record, different_kinds, deals_are_off.

Starters (#222)

install_blueprint

A blueprint is a named setup: a list with the sentence people agree to, the emails, the automations written as sentences, and where there are any, a saved view and a chart. One call does what a founder would otherwise ask for a dozen tools to do, and each of those dozen is a chance to get a stage key or a consent sentence wrong.

install_blueprint
Takes keysaas, waitlist or release-notes
Answers what it wrote, what it could not write and why, and the state of every starter

Nothing it writes is switched on. Every automation lands paused, exactly as it does when a person presses the button on Settings → Setup, so somebody reads each one before it can email anybody; the answer says so in its own section.

It writes nothing twice. A list is matched by its address, an email and an automation by name, a view by its key, a chart by its title, so installing a starter again adds only what is missing — which is the way to finish one that was held back.

What it could not install, it says. An automation naming a stage, an object or an event this workspace has not got is skipped with one sentence naming the screen that would make it exist:

Skipped “When production deploys, draft the release notes”: Connect Vercel first: Settings →
Integrations makes the Releases object this one watches, and the deployments start arriving as
records in it. Install this starter again once that is done.

describe_workspace carries the same facts in one line:

BLUEPRINTS
SaaS starter installed (connect Stripe first); Waitlist starter not installed; Release notes
partly installed (connect Vercel first). install_blueprint puts one in; everything it writes
is paused.

Refusals: not_allowed (a member — owners and admins only, as on the screen), unknown_blueprint, which names the starters, unknown_argument, bad_argument.

docs/blueprints.md has what each starter holds, the file shape, and how a workspace exports its own setup as one.