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_siteconnects 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_recordand 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 ordertools/listgives them, and the rest are counted.) A name the client still holds that Kicklace has taken away is said too: "It still listsold_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
stagenames 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. organizationlinks 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:
contactedstayscontacted,{{phone}}stays{{phone}}, and an object renamed from Releases to Shipments is still the slugreleases. 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 exactly — when, 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 | key — saas, 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.