Skip to main content
A playbook is the instruction the CLI hands to your agent. Each playbook is keyed by a name and carries its own trigger in an on: block, and timeless playbooks run renders its prompt through your configured harness to produce an artifact.
Your machine has to be on. Playbooks run locally — the desktop app’s timer and your agent both live on your computer, so nothing fires while it is shut down or asleep. Event-driven playbooks catch up (the events wait in the queue and the first pass after wake leases them) and interval: playbooks resume on their next tick, but an at: time that passes while the machine is off is skipped — it does not run late.

Two kinds of trigger

Every playbook sets exactly one of on.event or on.schedule — that choice is what makes it event-driven or scheduled. Event-driven (on: { event: <type> }) binds the playbook to an ack-queue event type. A run leases pending events of that type, renders the prompt once per event, and acknowledges them. The event-type namespace is an open set; two types reach the queue today — meeting.transcript_ready (what playbooks init binds to) and meeting.initial_summary_ready. A pass is scoped to one type and never sees the other’s events. Scheduled (on: { schedule: … }) fires on a timer and never touches the queue. There is no triggering event, so the prompt fetches whatever data it needs itself (for example timeless meetings upcoming). Set one of:
  • interval: 30m — a positive Go duration.
  • at: ["09:00"] — one or more HH:MM 24-hour local times, optionally narrowed with weekdays: [mon, tue, wed, thu, fri].
Timing lives inline, in the playbook. There is no timeless schedule command and no schedule.yaml: the desktop app’s resident timer derives what to run, and when, from the playbooks: map directly. Seeding a playbook is scheduling it.An event-driven playbook is polled by the app on a built-in 5-minute cadence. Override it per playbook with on.every: 1m. An empty playbooks: map runs nothing — there is no implicit default.

Where they live

Playbooks live under the playbooks: key in your config file at ~/.config/timeless/config.yaml (respects TIMELESS_CONFIG_DIR / XDG_CONFIG_HOME). Run timeless playbooks init to seed the defaults — it is idempotent and never overwrites a prompt you have edited. On a fresh machine it installs transcript_notes. timeless playbooks list --json shows what’s configured and reports each trigger as event=<type> or schedule=….
Each playbook accepts:

Prompt variables differ by kind

The prompt is a Go text/template. The variable set depends on the trigger, so a scheduled prompt that references an event-only variable fails at run time. Event-driven:
  • {{.EventID}} — the queue event id
  • {{.EventType}} — e.g. meeting.transcript_ready
  • {{.MeetingID}} — the payload’s meeting_id, falling back to its id (which is the key real payloads use); empty only if the payload carries neither
  • {{.Payload}} — the full event payload as compact JSON
  • {{.OutPath}} — the absolute path the CLI expects the artifact written to
Scheduled:
  • {{.Today}} — the run’s local date, 2006-01-02
  • {{.Now}} — the run’s local timestamp, RFC3339
  • {{.OutPath}} — as above
A run succeeds when a non-empty file exists at {{.OutPath}} — the harness’s exit code is ignored. Every prompt should end by writing there. Artifacts land in ~/.local/share/timeless/artifacts and the desktop app surfaces each one to you in-app; the CLI never opens a browser, so keep them self-contained.
The path is CLI-owned — always use {{.OutPath}} rather than composing a filename. It takes one of two shapes: <event type>-<event id>-<playbook>.html for an event-driven run (the playbook name is in there precisely so several playbooks on one type cannot overwrite each other) and <playbook>-<YYYY-MM-DD>.html for a scheduled one, where there is no event id to key on.
When a prompt cannot do its job, it must write nothing. The CLI never reads the file’s contents, so a document explaining what went wrong is a successful artifact as far as it can tell — and the app delivers it to you as your finished recap. Leave {{.OutPath}} absent and print the reason instead; the attempt is then recorded as a no_artifact loss and the app can tell you that deliverable failed.

Run them

The unit of an event-driven pass is the event type, not a playbook. A name may only be used for a scheduled playbook; naming an event-driven one is refused, because two invocations racing for one event is exactly what --event-type prevents.
Delivery is at-most-once: each event is acknowledged once, before any attempt. An event gets one round of attempts, and one that produces nothing is reported in losses rather than retried. A failed run is not redelivered on the next tick.

Stale events are skipped, not run

An event whose meeting time is older than events.max_age (default 24h) is acknowledged and skipped: consumed for good, but no playbook runs and no artifact is written. It is reported as expired and the pass still exits 0 — a skip is not a failure and never means a recap went missing. This is what stops three days away from producing a burst of dozens of recaps for meetings you have moved on from. Widen the window when you want a backlog processed on purpose — --max-age 168h for one pass, or timeless config set events.max_age 168h to persist it. There is no undo once an event has been retired.

Turn one off without deleting it

These write the playbook’s enabled key. Nothing is deleted — the prompt and trigger survive, so enable restores it exactly. Both are idempotent, and they govern future runs only: a pass already in flight still finishes, so one more recap can land right after you disable something. That run already consumed its event; let it land.
playbooks list does not show enabled state — it reports name, trigger, harness, and prompt only. Read config.yaml, or trust the changed field disable returns.

Several playbooks can share one event type

This is the point most often gotten wrong, and older guidance said the opposite. Sharing a trigger is correct — it is the supported way to get several deliverables from one meeting. timeless playbooks run --event-type <type> acknowledges each event once, then runs every enabled playbook bound to that type, in name-sorted order, each writing its own artifact. Paths are keyed <event type>-<event id>-<playbook name>.html, so they cannot collide. Add a CRM sync and a Slack digest alongside transcript_notes — all three on meeting.transcript_ready — and one transcript produces three artifacts. Nothing needs to be renamed or removed to make room. Two related guarantees: events pull is read-only and can never cost an event its attempt, and scheduled playbooks never touch the queue at all.
Each playbook is a full agent run and they execute sequentially, so cost and wall-clock grow with the size of the set for one event type.

Let your agent customize them

There is no timeless playbook command — playbooks are just YAML, so you edit them directly or ask your agent to. The agent already knows the file location and the template contract, so you can describe the change in plain language:
  • “Explain my playbooks” — the agent reads the config and summarizes each one.
  • “Have the recap include action items with owners” — the agent edits the prompt in place, preserving the template variables and the write-to-{{.OutPath}} step.
  • “Poll for transcripts every minute” — the agent sets on.every: 1m.
  • “Pause the weekly brief” — the agent runs timeless playbooks disable weekly_brief.
  • “Run transcript recaps on Codex” — the agent sets harness: codex on that playbook.
  • “Add a Monday-morning digest” — the agent adds a playbook with an on.schedule trigger.
After a change, run timeless playbooks list --json to confirm the trigger parsed as you intended, then exercise it with timeless playbooks run.
When you edit a prompt, keep the instruction to write a complete, self-contained HTML document to {{.OutPath}}. Drop it and the run produces no artifact — and because delivery is at-most-once, that event is reported as a loss rather than retried.