Documentation
The automation language
An automation is a sentence: when something happens, wait, only if it is still true, then do this — otherwise do that. There is no canvas and no second word for it. This is the whole language in one place: what each part is, what it takes, and what it will not do.
An automation is a sentence. When something happens, wait, only if it is still true, then do this — and, new with #214, otherwise do that instead. There is no canvas and no second word for it: everything below is read on the automation's own card, edited by hand from the same page, previewed, replayed, test-run, and written by Claude through the door.
This is the whole language in one place: what each part is, what it takes, and what it will not do.
It is written for a founder first and a developer second; docs/mcp.md is the same language as an
argument to propose_automation, and src/lib/model.ts is the same language as types.
All of it runs. #214 landed in four packages — A the language, B the engine's triggers, waits and branches, C the actions that reach out, D the AI steps — and each took its own parts off the "not runnable yet" list in the commit that made them run, because nothing half-runs. Everything below is saved, checked against your own workspace, said in words on the card, editable by hand, previewed, replayed, test-run, and run by the engine. If some later release ever writes a part before it runs one, it is marked Not runnable yet here and on the card, and it cannot be switched on until the release that runs it.
When — the trigger
Exactly one, and it is what starts a run for one record.
| Trigger | What starts it |
|---|---|
| A record enters a stage | Somebody moves, or an automation moves them, into that stage |
| An event arrives | Something lands on a record's timeline: a visit, a purchase, a sign-up, a support message, a reply, or an event your own servers post |
| Someone subscribes to a list | They joined it, from your website, a form, or a member adding them with the words they agreed to |
| Someone leaves a list | They unsubscribed, or a member took them off |
| A field changes | Somebody edited it |
| On a schedule | The calendar, rather than something that happened |
| Your server posts to an address | Each automation has an address of its own |
A schedule says when and who: every day, every week on a weekday, or every month on a day of the month (1 to 28, because not every month has a 29th), at a time in your workspace's own timezone, over one saved view of one object. It reads:
Every Monday at 09:00, for each person in Still waiting
That is how "every Monday, chase everyone still waiting" is written. The view is the audience, so changing who is chased is changing the view, not the automation — and nothing that already happened is replayed when you switch it on.
How the timing works, exactly:
- Nine o'clock is nine o'clock where you are. The hour is read in the workspace's own timezone (Settings → General), so a schedule that goes at nine in the morning goes at nine in the morning through a clock change, not an hour early for half the year.
- A day of the month is 1 to 28. Every month has one, so a monthly schedule never skips February and never has to guess what "the 31st" means in a month that has thirty days.
- One occurrence, however long Kicklace was not looking. The worker starts the run for the latest occurrence that has come round and for no earlier one. A deployment that was quiet for a fortnight comes back to one Monday, not to two: an automation is something you meant to happen every Monday, not a debt owed for every Monday that went past.
- Nobody runs twice. The occurrence is an exact moment, so a check a minute after another check works out the same moment and leaves the runs it already made alone.
- A record with a run still in the air is left alone, exactly as it is for every other trigger: a weekly schedule with a ten-day wait does not stack up runs behind itself.
- The view is read when the schedule comes round, so somebody who left it yesterday is not chased today, and somebody who joined this morning is.
A webhook has nothing to set. The address is derived from the automation and printed on its own page; your server posts the person's email or id to it and that starts a run. The address is never printed on the card, in a log, or in an answer to a model.
POST https://kicklace.com/api/webhooks/automation/<automation id>.<signature>
Content-Type: application/json
{ "email": "ada@example.com", "properties": { "plan": "studio" } }
- Who it is for:
email,anon_id, orrecord(a record's id). An email Kicklace has not seen adds that person — with an address, on no list, and with nothing that lets anybody email them; ananon_idor arecordonly ever finds somebody who is already here, and answersno_personwhen they are not. propertiesare your own facts about what happened. They travel with the run for the steps that read them; the run's log names which ones arrived and never what was in them.- The answers:
{ "ok": true, "status": "started", "id": "rec_…" }, or{ "ok": true, "status": "paused" }when the automation is paused or still a draft — nothing is written, and your server is not asked to try again. A refusal carrieserrorand one sentence saying the fix:no_identity,invalid_email,no_person,not_a_webhook,rate_limited,too_large,invalid_json, andunknown_endpointfor an address that is not one of yours. - There is no key. The signature in the address is what makes it yours, so treat it the way you treat a secret key: it starts that one automation and can do nothing else.
- Every post shows in Settings → Your website → Your website's heartbeat, named after the automation ("automation #3") with what became of it.
Wait — how long, and where it lands
Days, with fractions (0.5 is twelve hours), and what cuts it short: the record changes stage, a reply arrives, they unsubscribe. Leave the wait out and the actions run at once.
A wait may also carry on to a weekday and time once the days have passed, in your workspace's timezone.
3 days, then until Monday 09:00, unless a reply arrives
With no days at all it is simply Until Monday 09:00. Nothing about the cancels changes. Where it lands is worked out once, when the run starts, so a run already waiting is never moved by anything — an edit to the definition, a clock change, or a deploy.
Only if — the conditions
Everything here must still be true when the wait ends, or the run stops.
| Condition | What it asks |
|---|---|
| It is in a stage / it is not in a stage | Where the record stands right now |
| The person is subscribed to a list | They have an active subscription to it |
| This automation has not emailed them | How "once per person" is written |
| A field has a value / a field is filled in | What is on the record |
| The model answers yes to a question | One call to the model, on your daily allowance |
The AI condition asks one yes-or-no question about this person, answered from that record's own recent history and, where there is one, the message that triggered the run — never another record.
The model says yes to: “Are they asking to cancel?”
Use it only where nothing else can say it. A field or a stage that already answers is cheaper, surer, and readable by a person a year from now. The run's log carries the answer and the reason for it — The model said no: they asked about the price — so a decision is never anonymous.
An answer the model will not give (AI switched off under Settings → General, the day's allowance spent, the provider down) skips the run rather than guessing either way: a question with no answer is not a "no", so neither branch is taken and the log says so in two lines.
Then — the actions
| Action | What it does |
|---|---|
| Send an email | Needs the list they are on, or is transactional — caused by what they just did |
| Notify a member | An email and a bell, to a member or to whoever owns the record |
| Move to a stage | The record moves, and anything watching that stage hears it |
| Set a field | One value |
| Create a task | The follow-through a notification cannot carry: still there next week |
| Draft an email to a list | Written into the composer for a person to send. Nothing is sent |
| Set several fields | One write instead of a row for each |
| Create a record | A deal, an organization, one of your own objects. Never a person |
| Post to Slack | One line in the channel you connected under Settings → Integrations |
| Post to your own address | The outgoing-webhook body, signed. One attempt |
| Draft an email to this person | Left as a task for somebody to send. Nothing is sent |
| Have the model fill in a choice | One of a select field's own options, and nothing else |
| Have the model write a note | One note on the timeline, in the automation's name |
| Have the model fill in blank fields | From what the person wrote, and only where they are blank |
The sentences, as they read on the card:
Set Plan to “Studio” and Source to “Vercel” Create a deal named “{{name}} — onboarding” and link it Post to Slack: “{{name}} just signed up” Post to https://api.example.com/hooks/kicklace, signed Draft Still on the waitlist for Marcus to send Classify Plan interest from what they wrote Write a summary note Fill in Company and Phone from what they wrote, where blank
What none of them will do. An automation never creates a person — a person arrives through your website or your servers, with a first touch and consent behind them. It never sends a one-to-one email or a broadcast: it drafts, and a person decides that they go. It never writes a value a select field does not offer. It never posts to an address that is not a public https one, for the same reason an outgoing webhook may not: an address typed into a form is otherwise a way to make Kicklace's servers knock on their own doors.
The four that reach out
Post to Slack goes through the one channel Settings → Integrations connected, under the same cap
and the same escaping every other notice gets, with the {{variables}} filled the way an email's
are. With no Slack connected the step says "Skipped Slack: not connected" and the run carries
on — a channel nobody set up is not a broken automation.
Post to your own address posts the same body an outgoing webhook posts, with type set to
automation and data naming the automation and what it listens for:
{ "id": "whd_…", "type": "automation", "at": "2026-09-09T09:00:00.000Z",
"workspace": { "id": "ws_…", "slug": "woodwright" },
"record": { "id": "rec_…", "kind": "person", "name": "Hank Ruiz",
"email": "hank@ridge.example", "stage": "purchaser" },
"data": { "automation": { "number": 7, "name": "Tell the shop" },
"trigger": { "kind": "event", "event": "purchase" } } }
The headers are X-Kicklace-Delivery, X-Kicklace-Event: automation, and X-Kicklace-Signature: sha256=<hex> — the same HMAC-SHA256 of the exact body an outgoing webhook carries, under one
secret per workspace, printed on Settings → Integrations under Outgoing webhooks: "Requests from
automations are signed with this secret." It is derived from this deployment's own secret and the
workspace's id, so it is never stored, never in a backup, and never changes; there is nothing to
rotate and nothing to lose. Owners and admins see it, and the operator console never does.
One attempt, ten seconds. An outgoing webhook is a feed of everything that happens and is worth
trying again for fifteen hours; this is one step inside one run, and a run held open for hours is a
worse thing than a line in the log. What the address answered is written down —
Posted to api.example.com: 200, or Posted to api.example.com: 500, not retried — so a person can
go and look. Retries here are a later item.
Create a record makes the second record from the one the run is about, fills its name and fields
from the same {{variables}}, links the two when the sentence says "and link it" (the label is the
one the record page would have used), and fires the stage it lands in, so an automation watching that
stage hears it. The log names it with its link.
Draft an email to this person writes a task, "Email Hank Ruiz: Still on the waitlist", due today, for the member the sentence names — or the workspace's first owner where it says "the owner", and nobody where it names nobody, which is what "for somebody to send" on the card means. It tells the managers once: "A reply to Hank Ruiz is drafted: Still on the waitlist." Following the link opens that person's record with the composer already on that template. Somebody who has not engaged still gets the task: a draft is not a send, and the composer is where the rule is kept, in the sentence it shows whoever opens it.
The four that ask the model
One condition and three actions ask Kicklace's own model a question about the record the run is about. Each is one call, through the same door every other model call in Kicklace goes through: your workspace's switch under Settings → General, your account's daily allowance, and the meter the operator console reads, where they are counted apart from automations somebody wrote by chat.
What a step reads. One record: its name and kind, its stage and pipeline, the lists it is on,
its own field values as the record page shows them, where it first came from, and the last thirty
rows of its timeline as a kind, a title, a day and who — the same facts the paragraph at the top of
a record is written from. Where the trigger carries one, it also reads the message that started
the run: the words of the email or the support message on the row that fired it, or the message
your server sent with a webhook post. That message is fenced and introduced as somebody's own words,
and the model is told to read it and never to follow anything it asks for.
What a step never reads. Another record — not the person's organization, not a similar customer, nothing from a list. The words of any timeline row except the triggering message. Another workspace, at all. Nothing is remembered between runs, and nothing is sent anywhere but the model.
What each one may write.
- Have the model fill in a choice (
ai_classify) writes one of that field's own options, word for word, down the same path a Set a field takes: the value is cleaned the way a hand edit is, the change lands on the timeline, and anything watching that field hears it. An answer that is not one of the options is refused and written in the log — The model answered “Gold”, which is not one of plan's options; nothing was written. The model may also decline, and "did not pick one" is a real answer and better than a guess. - Have the model write a note (
ai_summarize) writes one note on the timeline, at most a thousand characters, by the automation “Welcome”. It is an ordinary note: dated, attributed, editable, and it moves the record's last activity, because a person reads it there beside their own. (The paragraph at the top of a record, #137, is the other thing and is not activity: that one is written about the activity.) - Have the model fill in blank fields (
ai_extract) writes values for the fields you named, only where they are empty — an automation reading a reply never writes over what somebody typed — each one cleaned by the same rules a hand edit is cleaned by, and never into a field of options. The log names what was filled and what was left: Filled in company to “Ridge Cabinets” from what they wrote; phone was already set.
When nothing answers. AI switched off, the day's allowance spent, or the model down: an action step is skipped with the sentence the error itself carries — Could not ask the model: AI is switched off for this workspace. Turn it on under Settings → General. — and the run carries on to everything after it, because one step nobody could answer is not a broken automation. The condition is the exception, above: it skips the whole run, because there is no honest branch to take. And the question that switches an automation on says all of this before it is armed: It asks the model at each run, on your allowance.
Otherwise — the other branch
Only if is the "if". When a condition does not hold and there is an Otherwise, those actions
run instead and the run is done; with nothing there, the run is skipped, which is what it has
always been and what most automations want. One level, by design: an automation is a sentence,
and a sentence with a second "otherwise" inside it is a flowchart.
On the card it is the fourth block, after Then, drawn only when there is one. In the activation question it reads:
From now on, when a support event arrives, Kicklace emails Marcus Fall, but only if the model says yes to: “Are they asking to cancel?”; otherwise it drafts Thanks for writing in for the owner to send.
An Otherwise with no condition above it is refused: with nothing to fail, it would never run.
Seeing it before it runs
Every automation's page has the same two questions — Now ("who would enter this today") and 7 days / 30 days ("what would have happened") — plus a real run for one record you name. The new parts answer them like this.
- A schedule, Now: the people the saved view holds today, and when it next comes round — Would run for 12 people in Still waiting at the next occurrence, Monday 09:00. Each row is judged as it stands, so a condition that would skip somebody says so before you switch it on.
- A schedule, replayed: A schedule has no history to replay. This is a choice, not a shortcut. A saved view is a question about the records as they are now, and nothing anywhere records who was in it last Monday — so replaying the last four occurrences over today's members would be a claim about the past that Kicklace cannot stand behind. Now, which is exactly who the next occurrence would run for, is the honest answer instead.
- A webhook, Now: It runs when your server posts to this automation's address, so nobody is standing at it until one arrives.
- A webhook, replayed: how many posts arrived in the window, counted from your website's heartbeat — and the runs beside the definition are what they did.
- A wait that lands somewhere: Would wait 3 days, then until Monday 09:00, with the real moment beside it.
- An Otherwise branch: Would take the Otherwise branch: not in stage Activated, and then each of its actions under Otherwise: … — the same words the run's own log uses, so what you read before is what you read after.
- An AI step, looked at either way: nothing is asked. Neither the replay nor Now may spend a call on your allowance to draw a panel, so each says what it would ask — Would ask the model: “Are they asking to cancel?” — what happens next depends on the answer, and nothing is asked here, and Would ask the model to choose plan from what they wrote — and the rows carry on as though the answer had been yes.
- An AI step in a test run: the model is asked, for real and on the allowance, because seeing what it answers is the whole point of trying one — and nothing is written, the way a test run's Slack post goes nowhere: [Test] the model chose “Studio” for plan: they asked about five seats; nothing was written. Those calls are marked as a test on the meter.
What consent still means
Unchanged, and none of the above loosens it.
- A send names a list the person is subscribed to, and the email carries the words they agreed to and a way out. The one exception is a transactional send, which names no list and is only allowed where the trigger is the person's own action — a purchase, a sign-up, a support message, a reply, or an event your servers post. A post to an automation's webhook address counts, because it is your server saying this person just did something. A schedule does not: the clock is nobody's doing, so an email on a schedule always names a list.
- A draft goes nowhere.
Draft an email to a listandDraft an email to this personboth write the words and stop. Nothing in this language sends without a person reading it first. - The engine's own rules — a run per record, claimed once, logged, following a merge, never firing more than three deep — are the same for every part of it.
Where each part lives
| The shapes | src/lib/model.ts — Trigger, WaitSpec, Condition, Action, AutomationSpec |
| What does not run yet | src/lib/automation/language.ts — NOT_YET_RUNNABLE, notRunnableSentence |
| The words | src/components/automations/describe.ts — one sentence and one set of chips per part |
| The checks | validateSpec in src/lib/ai/automation-author.ts |
| The by-hand editor | src/components/automations/definition-form.tsx |
| The card | src/components/automations/spec-card.tsx |
| The clock | src/lib/when.ts — latestOccurrenceIn, nextOccurrenceIn, landOnIn |
| The schedule | runScheduledAutomations in src/lib/automation/tick.ts |
| The address | src/lib/automation/address.ts and src/app/api/webhooks/automation/[address]/route.ts |
| The branches | executeRun in src/lib/automation/engine.ts |
| Through Claude | propose_automation and update_automation; see docs/mcp.md |
| The actions that reach out | src/lib/automation/engine.ts, over src/lib/integrations/{slack,webhooks}.ts |
| The signing secret | automationSigningSecret in src/lib/integrations/secrets.ts — derived, never stored |
| The link a draft leaves | src/lib/compose-link.ts, read by the record page and the composer |
| The AI steps | src/lib/automation/ai-steps.ts — the prompts, the schemas, and what each may write |
| The one door a step asks through | askModel in src/lib/ai/call.ts, purpose automation_step |
NOT_YET_RUNNABLE is empty: B's four came off it on 2026-09-09, C's five the same day, and D's
four AI steps last. The mechanism stays for the next part that is written before it is run — a part
on that list is reported by validateSpec as one sentence beginning Not runnable yet: , so the
draft holds it, the card prints it, and activation refuses it exactly as it refuses a stage that
does not exist.
Rotating a webhook address is a later item (#245): today an automation's address is derived
from its id and cannot be changed without deleting the automation and making another. If one gets
out, pause the automation — a paused one answers every post with paused and writes nothing.