tulipfarm docs
Concepts

Routines

Durable, repeatable automations that run without you — and pause for you when it matters.

A routine is a repeatable automation: a named sequence of steps that runs on a trigger instead of being improvised in chat each time. Where an agent decides what to do in the moment, a routine does the same thing every time — record the expense, check the amount, ask for approval over the limit, file the record.

You build a routine the way you build everything else in TulipFarm — by describing it to the assistant. The assistant writes a routine.yaml under soul/routines/{name}/ and commits it; the definition is the result of the chat, not something you edit. The Routines guide walks through creating and running one.

Five ways a run starts

A routine declares its triggers, and only declared triggers can start it:

TriggerStarts a run when…
manualYou press run now in the Routines section (or ask the assistant to). Inputs come from a form generated from the routine's input schema.
cronA schedule fires — 0 9 * * 1 for every Monday at 09:00, in the routine's timezone (UTC unless it says otherwise).
webhookAn external system POSTs to the routine's webhook URL with its shared secret.
eventSomething happens inside TulipFarm — a record is created or updated, a chat starts or completes. An optional filter expression narrows which events count.
agentAn agent decides mid-chat that the routine should run, via its trigger_routine tool.

Every path funnels through the same gate: the trigger must be declared, and the payload is validated against the routine's input schema before a run exists.

Five kinds of step

A routine's body is a chain of states, each one of five types:

  • operation — do something: call a platform tool, or hand a task to an agent ("summarize this and file it") that reports back when done.
  • switch — branch on the data: context.amount > 500 goes one way, everything else goes another.
  • foreach — repeat the same actions for each item in a list (bounded at 1,000 items per run).
  • sleep — wait: ten minutes, a day, a week. The run costs nothing while it sleeps.
  • inject — set data into the run's context for later states to use.

Data flows between states through small JavaScript expressions. Every expression runs in a locked-down sandbox — 100 milliseconds, no filesystem, no network, no host access — so a generated routine cannot reach anything the platform did not hand it.

Runs are durable

Starting a routine creates a run, and a run is built to survive:

  • The run pins a snapshot of the definition at start. Editing the routine mid-flight never corrupts runs already in progress — they finish on the version they started with.
  • A sleeping or waiting run holds no process. It is a row in the database with a wake-up call scheduled; restart the server and the run resumes where it left off.
  • Failures follow the routine's own error policy: retries with backoff, then onErrors routing to a recovery state, then — only if nothing matches — a failed run with the error preserved.
  • Each state can carry its own timeout; a stuck step fails into the same error routing instead of hanging the run.

Every run keeps a permanent journal of what happened — state entered, action completed, retry scheduled, run finished — and the Routines section streams that journal live while a run executes. Reconnecting picks up exactly where the stream left off, however old the run is.

Pausing for a human

A state marked as requiring human approval stops the run before executing and files a request in the app's Approvals section — the same place gated agent tool calls appear. The run shows waiting approval until someone decides; approve and it continues, deny and the denial routes through the routine's error handling like any other failure.

Approval requests always appear in the app. If a Slack integration is connected, the request is also posted there. Channels the platform cannot deliver yet (email, SMS) fall back to the in-app request with a logged warning — a routine never silently loses its gate.

Routine approvals wait seven days. An undecided request times out, counts as a denial, and the run routes through its error handling rather than waiting forever.

Hooks: custom logic, still sandboxed

A routine can ship a hooks.ts next to its routine.yaml for logic that is awkward to express in steps — computing a total, normalizing a payload. This is what the assistant writes underneath (you never author it by hand):

soul/routines/expense-report/hooks.ts
({
  beforeHook(ctx) {
    // runs once when the run starts
  },
  afterCheckAmount(ctx) {
    // runs after the CheckAmount state completes
  },
  computeTotal(ctx, args) {
    // callable as a step action
    return args.base + ctx.context.surcharge;
  },
})

Hooks run in the same sandbox family as the expressions — isolated, time-boxed, hash- verified against the committed source — with before/after functions for the whole run or any state, plus named functions a step can call directly.

Deferred in V1

Some workflow constructs are recognized but deliberately rejected rather than silently ignored: parallel fan-out states, event-correlation states, sub-routine calls, and the datetime and integration trigger types. A definition using one fails validation with a "deferred in V1" error, so a routine never half-works. They arrive in later releases as real routines demand them.

On this page