Documentation
The MCP server
Point Claude, or any agent, at a workspace.
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 and the assistant it came through ("via Claude") on the record's timeline.
- Every call it makes, refused or not, is one line under Connections → Claude.
Signing in (OAuth 2.1)
A connector list (claude.ai's, Claude Desktop's, Claude on a phone) doesn't paste keys. It finds the sign-in from the server 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 all their workspaces or one of them 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 server 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, so it can do what they can do and no more. It is listed under Connections → Claude as a Connected app, with a Disconnect that stops it at once.
Which workspaces it reaches is the one choice on that screen. All my workspaces is the default: the connection reaches every workspace the member is in at the moment it asks, so one they join next month is included and one they leave stops being reachable at the next call; there is nothing to reconnect either way. Picking one workspace instead binds the connection to that one, whatever else they join. Somebody in one workspace is offered the same choice, and the row says what it covers ("Hollow Oak, and any you join later"), because Claude keeps one connection per address, so a workspace made next month arrives on the connection they already have.
A connection that reaches all of them shows on Connections → Claude in every workspace the member opens it in, reading "All your workspaces · last used 2 h ago in North Shelf", and Disconnect ends it from any of them.
The two kinds of key
| Key | Minted at | May |
|---|---|---|
Your own (purpose: "claude") |
Connections → Claude, by you | read and write, as you |
A server key (purpose: "server") |
Connections → 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 Connections → 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.
A key is one workspace's. It was minted there and there is nobody at the other end to ask, so a call from a key that names another workspace is refused:
This key belongs to North Shelf, and a key is one workspace's. Leave "workspace" out, or sign in from your app under Connections → Claude, which can act in every workspace you are in.
Which workspace a call is about
Every tool takes an optional workspace, by slug or by name:
{"jsonrpc":"2.0","id":4,"method":"tools/call",
"params":{"name":"find_people","arguments":{"workspace":"north-shelf","stage":"signed_up"}}}
It is the server's argument rather than a tool's: the server settles it, takes it off the arguments and hands the tool a context for that workspace, so no tool has to know it exists.
Where a connection reaches only one workspace (a key, or one allowed a single workspace), it does
not appear in tools/list at all, and naming another workspace is refused.
Where a connection reaches all of a member's workspaces, a call with no workspace lands in the one
that connection last acted in, or in their only workspace where they have one. Where neither
settles it:
Say which workspace: pass "workspace" as north-shelf (North Shelf) or kicklace (Kicklace).
Naming one makes it the one later calls use, so it is usually said once. describe_workspace lists
them all under WORKSPACES with the current one marked, and initialize names them too.
The membership behind all of this is read at every call: leaving a workspace stops every connection reaching it at the next call, with no reconnecting and nothing to clean up.
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 (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 on the consent screen whether it works in all your
workspaces or one of them. It appears under Connections → 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_….
Found without being told the address
packages/kicklace/server.json describes this same address in the shape the open MCP
registry publishes servers in: a name, what it does, and the one
remote at https://www.kicklace.com/api/mcp over Streamable HTTP. Once it is submitted, a client
that already searches that registry finds Kicklace the way it finds anything else, with nothing
pasted in by hand.
When Kicklace adds a tool
Kicklace answers capabilities.tools.listChanged: false, and that is the truth rather than an
omission: the server 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 server 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. Connections → 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 customer follow-up for small businesses: it turns the interest your website earns into customers, following up with every lead in your own voice. A workspace holds a product's customer graph: people, organizations, subscriptions, events, releases, and the lifecycle worked out from them. You are connected to the workspace “North Shelf” and acting as Grace Hopper, 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. A connection that reaches all of a member's workspaces is
given the list instead, with the one a call lands in when it names none.
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",
"title":"Find people",
"description":"…what it does, when to use it, and what it will not do…",
"inputSchema":{"type":"object","properties":{…},"additionalProperties":false},
"annotations":{"title":"Find people","readOnlyHint":true,"destructiveHint":false,"openWorldHint":false}}
]}}
A read tool changes nothing and says readOnlyHint: true, so an assistant may run it without
asking. Every tool that writes says destructiveHint: true, a proposal included, so the
assistant asks you first. openWorldHint is true only where a tool reaches past the
workspace: send_email, send_broadcast and invite_member (someone outside gets an email,
once a person approves or presses), and refresh_dossier and correct_dossier (they read the
open web).
Every tools/list is written down on the key or the grant that asked, which is what lets
Connections → 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 · Signed up · …"}],
"structuredContent":{"id":"rec_…","name":"Greg Halloran","stage":"signed_up"}
}}
structuredContent is there only when the tool has data to give; content always is.
prompts/list
Answered with {"prompts":[]}. Kicklace offers no prompts, but a client that probes should read
"none", not "no such method".
Resources: the graph, read rather than called
Ten resources, the graph as data. resources/list and resources/templates/list name them;
resources/read answers application/json, the same shape GET /api/v1/ in
Reading your data answers; all three run through one reader, so a
tool, a resource and a REST call can never disagree about one person.
{"jsonrpc":"2.0","id":6,"method":"resources/list"}
{"jsonrpc":"2.0","id":6,"result":{"resources":[
{"uri":"kicklace://people","name":"People",
"description":"Every person, narrowed by q, stage, population, organization or updated_since…",
"mimeType":"application/json"}
]}}
| Resource | What it answers |
|---|---|
kicklace://people |
a page of people, narrowed the way find_people narrows them |
kicklace://people/{id} |
one person: identities, the stage, the money as two halves, the organization, Kicklace's own reading of them |
kicklace://organizations |
a page of organizations |
kicklace://organizations/{id} |
one organization |
kicklace://subscriptions |
a page of customer_subscriptions rows, each with the payer it belongs to |
kicklace://events |
a page of what people did |
kicklace://releases |
a page of deployments, newest first |
kicklace://releases/{sha} |
one deployment, by its sha or the version string it was tagged with |
kicklace://journey |
the seven lifecycle rungs, and how many are stuck |
kicklace://populations |
every population, with its count and the formula that qualifies it |
The first seven are on resources/list; the three with a {placeholder} are on
resources/templates/list instead, as uriTemplate rather than uri, the shape the protocol
asks the two lists for.
Every filter a REST address takes is a query parameter on the same uri:
kicklace://people?stage=paying&population=group:likely-to-leave&limit=20. workspace is one of
them too: the same argument a tool call takes, settled the same way: the one named, else the one
this connection last acted in, else its only one, else a refusal naming the choices.
{"jsonrpc":"2.0","id":7,"method":"resources/read",
"params":{"uri":"kicklace://people/rec_7f3a2c9e"}}
{"jsonrpc":"2.0","id":7,"result":{"contents":[
{"uri":"kicklace://people/rec_7f3a2c9e","mimeType":"application/json",
"text":"{\"id\":\"rec_7f3a2c9e\",\"name\":\"Ada Lovelace\",…}"}
]}}
A resource never writes, so nothing here is logged to agent_calls the way a tool call is; that
log is what an agent did, and a read did nothing. An unknown or malformed uri, and a workspace
this connection does not reach or did not say which of, is -32602 (Invalid params), the same
refusal a tool call with a bad workspace gets; a uri that parses and names a real workspace but
no record, release or sha there (kicklace://people/rec_gone) is -32002, the code the
protocol's own examples use for "that resource is not here". Both carry the sentence with the fix
inside it, the way every refusal here does.
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.
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, a workspace this connection cannot reach, 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 server 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 behind a token bound to one workspace, has left it. A connection that reaches all of a member's workspaces answers per call instead, naming the ones they are still in |
| 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.
Connections → Claude shows the last twenty rows and a 30-day count.
Records that are not people
find_people and create_person know one object. Everything else a workspace keeps
(organizations, and any object it added) goes
through these two. Both take an object by its slug from describe_workspace
(organizations, 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 that organization, which
can never be wider than the organization holds, so it and view aren't asked for together.
Each row is one line: the name, the stage (where the object has a pipeline, with the pipeline's name when it has more than one), the fields a person reads first, when it was last active, and the link.
2 organizations. Showing 1–2, newest activity first.
Harbor Millwork · Domain: harbormillwork.com · City: Portland · last active Sep 8, 2026 · https://…/directory/organizations/rec_…
Tessellate Tools · Domain: tessellate.dev · last active Sep 7, 2026 · https://…/directory/organizations/rec_…
The fields it picks are an organization's domain and city; and, for anything the workspace added
itself, that object's own first two fields, which is what its directory shows. What a customer is
worth and what state they are in (paying, trial, churned, free) is worked out rather than
stored, and a row with no money behind it carries neither; describe_workspace prints the counts.
Where Sentry is connected, an organization whose people hit an error in the last 7 days carries the
count beside the last-active date. get_person says the same for one person, with the title of
the last one. Kicklace holds no stack trace and no request: the link on the timeline row opens the
event in Sentry.
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, which names
an 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 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, exactly as it does when a member fills in the form. organizationlinks the two with the app's own label: member of from a person, partner of between two organizations.- The row is written in your name, on the timeline as "<member> via Claude".
Added the workshop Harbor kitchen run in Booked, for Harbor Millwork. https://…/directory/workshops/rec_…
The refusals
error |
When |
|---|---|
wrong_tool |
object named the People object; find_people and create_person know them |
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 |
Organizations, and whoever pays
An organization is a company or a team your people belong to. A customer is whoever pays: a person, or an organization. Both carry the ids the payment and sign-in providers know them by, either can hold a subscription, and the state (Paying, Trial, Churned, Free) is worked out from those rather than dragged into a stage. A person who pays nothing themselves reads the state of the organization they are in.
describe_workspace opens with the count and the money:
CUSTOMERS
41 customers: 18 paying, 3 trial, 6 churned, 14 free. $1,840 a month from subscriptions,
plus $310 a month from purchases over the last 90 days.
The two figures are never added into one. A subscription's monthly price is a fact; a run rate off the last ninety days of one-off purchases is an average, and a screen that printed them as one number would be saying something neither of them says.
Errors
Where Sentry is connected, describe_workspace carries one more section:
ERRORS
412 errors in the last 7 days, across 12 people in 5 organizations. Each one is on the person who
hit it, under the event name "error", with the level, the environment and the release on it;
and joined to the Release record where the release string named a deployment.
Sentry holds the stack trace, the request and the breadcrumbs; none of that is here.
The same person hitting the same issue inside the hour is one row with a count on it rather than four hundred, which is why the figure is asked for rather than counted off a timeline. There is no tool that connects Sentry: the Client Secret is typed into a form, never into a message.
update_organization
Writes, for owners and admins. Takes the organization, then name to rename it, stripeCustomer
to say whose a Stripe customer is, archive with a reason, or unarchive.
stripeCustomer is how "this card is the company's" is settled. A Stripe customer is a person
by default (usually the human who signed up), and naming one here writes the cus_… on the
organization and moves every subscription the person was carrying over with it. It is an id, not a
secret, and no key is ever a tool argument.
Archiving is what "this is not a customer of ours" does. The record leaves every list, count and roll-up and nothing about it is deleted: the people linked to it, its subscriptions, its timeline and its history all stay, and unarchiving puts it back exactly as it was. There is no Won and no Lost, because an organization is not something that closes.
link_person_to_organization
Writes, and any member may. Takes a person and an organization and makes that the person's own: the new link is written first and the old one taken off after it, so a refused link never leaves somebody in no organization at all. The label is the app's own: member of from the person, members from the organization. Naming the one they are already in changes nothing and says so.
merge_organizations
Two records of one company, folded into one. It is a proposal, exactly like
merge_records: the call writes down what it wants and answers with a link, and an owner or an
admin approves it. A merge can't be undone: the identities, subscriptions, people and timeline of
the one that goes move over and it is marked as folded in, which is why it is asked for rather
than done.
remember_this, forget_this
Write, for owners and admins. remember_this takes a kind (definition, goal, preference
or fact) and one sentence of 10 to 300 characters, and keeps it as something true about this
business; forget_this takes that sentence's id, or the sentence itself word for word, and takes
it back.
What they change is what every model Kicklace runs is told: the sentences go into
describe_workspace under WHAT KICKLACE KNOWS, into Ask Kicklace's own prompt, and into what
Kicklace writes for you. So "activation means somebody
created their first project", "we care most about turning waitlist signups into trials" and "follow
up personally rather than with a generic campaign" only have to be said once. Nothing leaves the
building and no record changes, and the same sentence said twice is kept once. Settings → Kicklace
shows all of them with a Remove on each.
read_history
Writes, for owners and admins. Takes one argument, source: stripe, vercel, clerk, kinde,
github or posthog.
A webhook only hears what happens next, and most workspaces have a past. This reads it. Stripe's customers become people (the humans who signed up), its subscriptions become rows on them and its payments become purchases on the day the money moved; Vercel's production deployments become releases on the day they shipped; Clerk's and Kinde's users become people with the day they signed up and the day they were last here, their organizations become organizations and their memberships put somebody in one; GitHub's sponsors become purchases, its stars visits and its issues support messages; everybody PostHog has seen with an address becomes a person with the day they were first seen and three figures saying how much they use the product, and the events that workspace named become rows on their timelines, the rest of what PostHog holds is counted onto the person and never stored. Every row goes through the same reading the live webhook uses, so a thing read here and its webhook tomorrow land on one record rather than two.
It takes no key. The read-only Stripe key, the Clerk secret key and Kinde's machine-to-machine
pair, and PostHog's personal API key, are pasted into their cards under Connections and
encrypted there; Vercel is read with the install's own token and GitHub with the connecting member's
own grant. A key is never a
tool argument, never in a message, and never in an answer, the same rule connect_database and
connect_resend keep.
One call reads for about fifteen seconds and the background check carries on by itself; running it again carries on from where it stopped, and nothing is ever read twice. Nothing sends and nobody is put on a list: a history being read is not a thousand things happening at once.
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 the app applies. 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 (“Renewal date” → renewalDate), and from then on that is what
saved views, chart specs, email variables, the Lifecycle map, 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 use 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 the object's own list. Adding a stage to a person's lifecycle is allowed; 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), 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 server 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 naming one.
Refusals: unknown_object, 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 the object's own list. The key is what update_person writes to, what a saved view or a chart spec
filters on, and what {{renewalDate}} resolves to in an email.
Refusals: unknown_object, 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
run_report answered and kept nothing, so "all web traffic monitored" was a question asked again
every morning rather than a segment somebody opens. create_view keeps one. It only adds: it
renames nothing, removes nothing and touches no record (those are in
Changing what is already there below), and it spends nothing,
because no model wrote it, so nothing lands on the AI meter. describe_workspace prints the
identifiers it takes: every object's saved views, with their keys.
create_view
Saves a filter over one object as a view, so a question about a segment becomes a place in People
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. This tool sets none of it, but the table People draws for the view remembers its
own columns and each one's width too, dragged or fit by hand later, the same way it remembers a
filter.
| 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 don't.
{"name":"create_view","arguments":{
"object":"people","name":"On the waitlist",
"filter":{"stage":"signed_up","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
Two tools for the question a founder asks first: is the site connected? They are this server'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 and 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 notes as the setup prompt and Your website and its forms |
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 doesn't 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
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 saved copy: the words a person starts a broadcast from, or picks from the template menu when writing to somebody from their record.
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,  is a picture, --- alone is a rule; and the answer
prints textToPlain's rendering of it back, so what the model reads is what the person receives.
A picture needs an https address and is sized to the card; an http:// or data: one is left
as the characters that were written, and the text part says [caption: https://…], because
nobody reading that part can see it. The whole language is in docs/email-design.md.
Variables are {{like_this}}: {{first_name}}, {{name}} and {{email}} work everywhere, any
Person field key works ({{phone}}, and whatever the workspace has added),
{{organization}} is the organization they are linked to, and {{signature}} is the sender's
sign-off, which the workspace sets under Settings → Email → Styles. 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."
A template Claude writes is written by "<your name> via Claude".
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 a person approves.
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 500 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 North Shelf. 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
500 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's 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 this server.
Changing what is already there
Everything above adds. The tools below change what a workspace already has (a stage, a field, an object, a view, a population's own rule, a template's own look), because a founder's Claude that can set a workspace up on Monday and cannot adjust it on Tuesday is only half of what it needs.
All of them are writes for owners and admins, and all of them 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.
Templates
| Takes | Does | |
|---|---|---|
restyle_template |
template, change |
Reshapes its copy, and may lean it away from the workspace's style |
template is its name, as describe_workspace prints it, or its id. change is what look they
asked for, in their own words: "make it an announcement, with the date up top", "no header, square
buttons", one sentence, not a definition.
It never writes a new word. Kicklace's own builder reshapes the copy (it can move a line, turn
a paragraph into a heading or a heading into a paragraph, split one into two, or line three
pictures up so they read as a row), and Kicklace checks that every sentence,
number, name, date, link and {{variable}} the old copy held is still in the new one, moved and
re-marked at most. A word dropped or added, beyond the little markup's own marks, is refused.
Where the words ask for a look the workspace's own style does not already give, the answer may
lean this one template on a subset of the eight choices propose_email_style sets for the
workspace as a whole (header, opening, align, buttonFill, buttonShape, sections,
pictures, frame), never a colour, a face or a name, which stay the whole workspace's, laid
over the workspace's style for this template alone. A leaning already there and not asked about
this time is kept, not cleared.
What comes back is the copy and a change list:
Restyled “The Mac build is ready”.
The build announcement opens the band now.
The download link is its own button now.
No header on this one.
It now leans: no header.
A restyle with a problem is never saved: a dropped or
added word, a changed number, name, date or link, a dropped {{variable}}, or a leaning outside
the eight choices goes back to the model once, and if it's still wrong nothing at all is written;
what the template already says is left exactly as it is. It sends nothing, deletes nothing, and
does not rename the template; update_template does that.
Refusals: not_allowed, unknown_template (naming the ones that are here), bad_argument.
Stages, fields and objects
| Takes | Does | |
|---|---|---|
rename_stage |
object, stage, name |
Changes what a stage is called; keeps its key, colour and description |
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. Beyond what the app refuses (a pipeline's last remaining stage) it
refuses one more thing by name: the first stage of a person's lifecycle, where everybody
the website sees arrives. 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 and Organization are what the ingest route, the lifecycle and the consent rules are written
against. 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, 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.
Views
| Takes | Does | |
|---|---|---|
rename_view |
object, view, name, description? |
Renames a saved view; keeps its key and its filter |
set_view_columns |
object, view, columns?, calcs? |
Sets which columns it shows, in order, and what a column's footer adds up |
rename_view changes the words and nothing about what the view asks for; re-filtering one is done
on the Lifecycle's Table, 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.
set_view_columns (#571, the People table feeling like a spreadsheet, #567) is the other half:
columns is the whole list, left to right, by the same keys describe_workspace prints (the
built-ins are stage, lists, activity; a field is its own key); calcs names one or more of
them by the same keys, each one of count, sum, avg, min or max. Either may be left out to
change only the other, and leaving out both is refused rather than a call that quietly did
nothing. Its filter never moves.
Refusals: not_allowed, unknown_object, unknown_view, view_not_saved, bad_argument (a
name too short or too long, a calculation not one of the five, or neither columns nor calcs
given). A view already called that is answered: nothing changed.
Populations
| Takes | Does | |
|---|---|---|
tune_population |
population, changes (a list of { parameter, value }) |
Changes one or more of a group's own numbers |
population is its key (group:quiet) or its name (Quiet); find_people, the Populations page
under People, and a screen's own subject line all give the key. Every named group declares its own
parameters (a word, a unit, a default, a floor and a ceiling), and changes names one or more
of them by their own key, with the new value in that parameter's own unit. Quiet's quietDays
(days, default 30), Slowing's steadyAt (a share of momentum, default 0.5), High-value's
topShare and minPayers, and Likely to leave's own quietDays (which only ever raises the
bar Kicklace already sets, never lowers it below 30) are the numbers there are today;
Payment issue has none, because nothing in the graph carries how long a card has been failing.
Only the numbers move. Nothing said in the conversation becomes the rule: a value outside its own parameter's floor or ceiling is refused with the range named, and nothing about the rule's own shape (what it checks, what it means) ever changes. Every member of that group is recomputed against the new numbers within a minute rather than waiting for the sweep's own hour, and the answer says what moved:
Changed Quiet.
quiet after is 21 days now, not 30 days.
3 people joined it; 1 left.
In Quiet when nothing at all has been recorded for 21 days.
https://www.kicklace.com/app/<workspace>/directory/people/populations
Refusals: not_allowed, unknown_population (naming the groups that have numbers), no_numbers
(a population with nothing to tune, named), unknown_parameter, out_of_bounds (the parameter's
own range named), bad_argument.
The workspace itself
The four screens a founder opens on their first morning, as eight tools: Settings → General,
Settings → Members, a list in bulk, and People. Everything here but export_record is for
owners and admins, exactly as those screens are.
Where these tools and the app differ, the tools are the narrower of the two. They will not move the
workspace's address (its slug), because every link anybody has saved points at it. They will not
touch the AI switch, which is the workspace's own half of the leash on the model and belongs to a
person. They 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 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 | either of name, 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. 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 a tool's to touch. There
is no deals argument either: deals were retired on 2026-09-10, and a call that sends one is
refused as unknown_argument saying so, rather than ignored, so a model still holding the old
tool list does not report that it switched something off.
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's 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 a tool call'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 |
the import screen, 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 sends nothing. 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,
unknown_field.
that person agreed to. delete_list doesn't remove one either: it's 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 this server.
Proposals
Three kinds of act leave the building: sending an email, destroying something, and cutting loose a key or an address 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:
Grace Hopper, 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 |
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, Slack, GitHub, PostHog, Sentry or Resend (#571) |
merge_records |
folding one record into another |
merge_organizations |
folding one organization into another |
All twelve 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.
Nothing destructive happens on the call. Each one still carries destructiveHint: true in
tools/list, as every write does, so your assistant asks you before it files the proposal.
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. Home carries it as a line at the top, where what needs you comes first; the link lands on the whole proposal:
- 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.