Skip to content

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_site connects a website address, and nothing touches a key, a sending domain, a plan or a member without a person approving it first.
  • Every write carries your name 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_record and 10 more. In the Claude desktop app, switch this connector off and on in the Code tab's list of connected MCP servers; in claude.ai, remove the connector and add it again. A key-based client re-reads the list when it starts." (That is the real sentence for a client connected before the setup round: at most three tools are named, in the order tools/list gives them, and the rest are counted.) A name the client still holds that Kicklace has taken away is said too: "It still lists old_tool, which Kicklace no longer has."
  • current: "Has all 62 tools; read 2 h ago."
  • never listed: nothing at all. A key minted before this was written down has nothing to say, and a guess would be worse than silence.

The protocol, exactly

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

initialize

{"jsonrpc":"2.0","id":1,"method":"initialize",
 "params":{"protocolVersion":"2025-06-18","capabilities":{},
           "clientInfo":{"name":"claude-code","version":"1.0"}}}
{"jsonrpc":"2.0","id":1,"result":{
  "protocolVersion":"2025-06-18",
  "capabilities":{"tools":{"listChanged":false}},
  "serverInfo":{"name":"kicklace","version":"0.1.0"},
  "instructions":"Kicklace is 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 stage names another, in any of the object's pipelines, exactly as it does when a member fills in the form.
  • organization links 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.

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, ![caption](https://…) 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: contacted stays contacted, {{phone}} stays {{phone}}, and an object renamed from Releases to Shipments is still the slug releases. So nothing that names one has to be rewritten, and a rename is never a way to repurpose something.
  • Sending, destroying and pasting a secret stay behind a person. Nothing here deletes a record, a list, a template or an object, and nothing here sends. The two that take something away take a stage (whose records move to a stage you name) and a chart, which is a saved question and not a record.

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.