@todoforai/tfa-wait
v0.2.6
Published
TODO for AI waitpoint engine — schedule future wake-ups or wait for external events (webhooks); results arrive as messages in the conversation.
Readme
@todoforai/tfa-wait
Wait for an expression of triggers — the result always arrives as a message in the conversation that created the wait, waking the agent with full context.
One primitive: a trigger is something that can fire (a moment in time, or a webhook). A wait is a boolean expression over triggers. That's the whole model — AND, OR, timeouts, schedules and streams are all just shapes of the same expression.
CLI (agent-facing)
tfa-wait '<expr>' [--repeat] [--until <time>] "<message>"
tfa-wait '3h' "Post the 20 remaining comments"
tfa-wait 'signup || 7d' "Signup: {{payload.email}} — verify domain, send welcome"
tfa-wait 'deploy && tests || 1h' "Ship it: deploy done AND tests green (or 1h passed)"
tfa-wait 'weekdays@9:00' --repeat --until 2w "Morning check-in"
tfa-wait 'deploys' --repeat --until 1d "Deploy: {{payload.status}}" # every fire delivers
tfa-wait list
tfa-wait cancel w_abc123
tfa-wait emit deploys '{"status":"ok"}' # agent→agent / testing (per-todo name or h_ key)The expression
Leaves joined by && (all must fire), || (any fires), () for nesting.
A leaf is one word — its shape says what it is:
| leaf | meaning |
|---|---|
| 3h 30m 2d | duration — fires that long from (re-)arming |
| 9:00 | clock time — next occurrence |
| mon@9:00 | day@time — mon..sun, daily, weekdays, weekends, 1st |
| 2025-07-01T09:00 | ISO date — that instant |
| deploy | webhook — per-todo name; mints a stable unguessable h_… URL |
| h_a1b2c3 | existing webhook by id (cross-todo / agent→agent reuse) |
Names are scoped to the todo: deploy in another todo is a different hook — no
collisions. The returned URL is the secret; wire it into CI/Zapier/anything.
The two flags
--repeat— when the expression satisfies: deliver, reset, re-anchor time leaves, wait for the next round. (Replaces both cron-style schedules and event streams.)--until <duration>— hard stop; the wait always finishes here at the latest (default and max: 30 days — beyond that use a calendar).
Message templates: {{payload.x}}, {{from.kind}}, {{won}}, {{at}} — filled from
the winning event. Delivery metadata is readable text: reason, source, time, whether
the wait is still active, and an indented payload.
Engine (tfa-wait serve)
One Bun process: HTTP API + 1s tick. Store is the backend's Redis/Dragonfly —
keys namespaced tfawait:*, with atomic winner selection via Redis GETDEL.
Delivery goes through the public TODOforAI API (addMessage), so no backend
changes or deploys are ever needed.
# production — same Redis as the backend (picks up $DRAGONFLY_URL):
TFA_WAIT_PUBLIC_URL=https://wait.todofor.ai tfa-wait serve --port 8787
# or explicit:
tfa-wait serve --port 8787 --redis redis://localhost:6379Routes:
POST /hooks/:key— public webhook fire; the unguessable key is the authPOST /wait·GET /wait·DELETE /wait/:id·POST /emit— authed viax-api-key(validated against the TODOforAI API)
Resolution rule (the entire semantics): the expression is flattened to DNF —
OR-groups of AND-leaves. Each fired trigger counts once per round. The wait
resolves when some group is fully fired; the until trigger ends it regardless.
--repeat delivers and resets instead of finishing.
Env
| Var | Meaning |
|---|---|
| TODOFORAI_API_URL, TODOFORAI_API_TOKEN | TODOforAI API (auto-injected in agent shells) |
| TODOFORAI_TODO_ID, TODOFORAI_AGENT_SETTINGS_ID | current conversation (auto-injected) |
| TFA_WAIT_URL | engine URL override (default derived: api.todofor.ai → wait.todofor.ai, localhost APIs → localhost:8787) |
| TFA_WAIT_PORT, TFA_WAIT_PUBLIC_URL, TFA_WAIT_REDIS | serve-mode config |
| DRAGONFLY_URL | store (backend's Redis URL; --redis overrides) |
Layout
src/parse.ts expression → DNF groups · time spelling → next fire (nextTime)
src/types.ts Waitpoint · Trigger · Envelope (2 tables + wire format)
src/store.ts Store contract + RedisStore — tfawait:* keys, GETDEL claim, per-todo names
src/engine.ts emit → mark fired → satisfied? → deliver (+tick, repeat re-arm, cancel)
src/create-wait.ts parse + resolve names + arm triggers; guarantees eventual completion
src/deliver.ts addMessage via public API — the only product touchpoint
src/server.ts metal adapter (Bun.serve + setInterval)
src/cli.ts '<expr>' / list / cancel / emit / serveDelivery reliability: resolutions are written to a durable outbox before delivery, then retried with backoff (5s → 30s → 2m → 10m → hourly) until the API accepts them.
Security note: waitpoint rows store user API tokens (needed to deliver as the owner) — they live in the backend Redis, same trust domain as the rest of user data.
