Agent Adapters
Adapters are YAML files that tell the agent how to handle incoming webhooks from your services and map user actions back to upstream API calls. Each adapter defines:
- How to authenticate incoming webhooks (bearer token, HMAC signature, or both)
- Which webhooks become notifications (conditional matching + expression-based payload mapping)
- What happens when a user taps an action (HTTP requests to upstream APIs)
Here’s a minimal adapter for Donetick, a self-hosted task manager:
owner: user_abc
webhook: auth: bearer: path: true secret: "${DONETICK_WEBHOOK_SECRET}"
notifications: - if: payload.type == "task.reminder" id: string(payload.data.id) actions: [done, snooze_30] body: | { to: vars.users["1"], title: payload.data.name, priority: payload.data.type == "overdue" ? "high" : "normal", state: { task_id: payload.data.id } }
actions: done: title: Done traits: [confirm] request: | { method: "POST", url: "${DONETICK_URL}/api/v1/chores/" + string(state.task_id) + "/do", headers: { secretkey: "${DONETICK_API_KEY}" } }
snooze_30: title: Snooze 30m traits: [defer] params: minutes: 30 request: | { method: "PUT", url: "${DONETICK_URL}/api/v1/chores/" + string(state.task_id) + "/dueDate", headers: { secretkey: "${DONETICK_API_KEY}", "Content-Type": "application/json" }, body: { dueDate: rfc3339(addMinutes(now, action.minutes)), updatedAt: rfc3339(now) } }
vars: users: "1": "user_abc"The rest of this guide covers each section in depth.
Webhook authentication
Section titled “Webhook authentication”Each adapter defines how to verify incoming webhooks in the webhook.auth block. Three composable layers are available — use one or combine them.
Bearer token (header or path)
Section titled “Bearer token (header or path)”The simplest auth: a shared secret compared against a header value or a URL path segment.
Header mode — the source sends the secret in a named header:
webhook: auth: bearer: header: X-Webhook-Secret secret: "${MY_WEBHOOK_SECRET}"Path mode — the secret is the trailing URL segment. Useful when the source doesn’t support custom headers (e.g. Donetick):
webhook: auth: bearer: path: true secret: "${DONETICK_WEBHOOK_SECRET}"With path mode, the webhook URL becomes /webhooks/{owner}/{adapter}/{secret}. The agent strips the secret segment before processing.
HMAC signature
Section titled “HMAC signature”The source may sign the request body with HMAC-SHA256 and send the signature in a header. This is what Vikunja, GitHub, and most webhook-aware services use.
webhook: auth: signature: algorithm: hmac-sha256 header: X-Vikunja-Signature secret: "${VIKUNJA_WEBHOOK_SECRET}"The agent computes HMAC-SHA256(secret, raw_body) and compares it to the header value. Both raw hex and sha256= prefixed formats (GitHub-style) are accepted.
Replay protection
Section titled “Replay protection”Rejects webhooks with timestamps outside a tolerance window. Useful as a second layer alongside bearer or signature auth.
webhook: auth: replay: header: X-Webhook-Timestamp tolerance: 5mThe header value can be RFC3339 or Unix epoch. Both formats are accepted.
Combining layers
Section titled “Combining layers”All three layers compose — they run in order and all must pass:
webhook: auth: signature: algorithm: hmac-sha256 header: X-Signature secret: "${WEBHOOK_SECRET}" replay: header: X-Timestamp tolerance: 5mNotifications
Section titled “Notifications”The webhook.notifications array defines which incoming webhooks become push notifications. Each entry has a condition (if), a stable ID, and either a body (visible notification) or a signal (silent server-side action).
Body mode — send a visible notification
Section titled “Body mode — send a visible notification”When the if condition matches, the body expression is evaluated to produce the notification payload:
webhook: notifications: - if: payload.type == "task.reminder" id: string(payload.data.id) actions: [done, snooze_30, snooze_60, snooze_1d] body: | { to: vars.users["1"], title: payload.data.name, subtitle: payload.data.type == "overdue" ? "Overdue!" : "Due now", body: (fetched.chore != nil && fetched.chore.data.res != nil) ? fetched.chore.data.res.description : "", priority: payload.data.type == "overdue" ? "high" : "normal", source: { adapter: "donetick", name: "Donetick", icon_url: "https://example.com/donetick.svg" }, click_url: vars.base_url + "/chores/" + id, state: { adapter_id: "donetick", task_id: payload.data.id } }Key fields:
id: Stable, source-provided identifier. Sending the same ID updates the existing notification (upsert). Used for APNs thread grouping.actions: Array of action IDs (defined in theactions:section). These become the interactive buttons on the iOS notification.state: Opaque metadata that round-trips through the device and back. Available asstatein action expressions. Use it to carry context like task IDs.
Signal mode — dismiss a notification silently
Section titled “Signal mode — dismiss a notification silently”When a task is completed or deleted outside of TipOff (e.g. via the Vikunja web UI), use signal: "clear" to remove the notification from devices:
webhook: notifications: # Task completed elsewhere — clear the notification - if: payload.event_name == "task.updated" && payload.data.task.done == true id: string(payload.data.task.id) signal: "clear"
# Task deleted — clear the notification - if: payload.event_name == "task.deleted" id: string(payload.data.task.id) signal: "clear"Under the hood, signal: "clear" triggers a source-initiated action on the server — it tombstones the notification and sends a silent push to remove it from all devices.
Notification ID generation with id_from
Section titled “Notification ID generation with id_from”If the source webhook doesn’t include a natural stable ID, use id_from to generate one deterministically from payload fields:
webhook: id_from: [payload.event_name, payload.data.project_id] notifications: - if: payload.event_name == "build.failed" actions: [retry, dismiss] body: | { ... }The agent computes SHA-256(field1 + "\x00" + field2 + ...) and prefixes it with gen_. This produces a stable ID that deduplicates correctly across repeated webhooks for the same event.
Actions
Section titled “Actions”The actions map defines what happens when a user taps a notification button. Each action has a title, traits, optional parameters, and a request expression that produces an HTTP request.
Simple POST
Section titled “Simple POST”The Donetick “Done” action — a straightforward POST with auth headers:
actions: done: title: Done traits: [confirm] request: | { method: "POST", url: "${DONETICK_URL}/api/v1/chores/" + string(state.task_id) + "/do", headers: { secretkey: "${DONETICK_API_KEY}" } }Expression-computed body
Section titled “Expression-computed body”Snooze actions use params to define the snooze duration, then compute the new due date dynamically:
actions: snooze_30: title: Snooze 30m traits: [defer] params: minutes: 30 request: | { method: "PUT", url: "${DONETICK_URL}/api/v1/chores/" + string(state.task_id) + "/dueDate", headers: { secretkey: "${DONETICK_API_KEY}", "Content-Type": "application/json" }, body: { dueDate: rfc3339(addMinutes(now, action.minutes)), updatedAt: rfc3339(now) } }The action.minutes value comes from params. Multiple snooze actions can reuse the same request expression with different params values.
Traits
Section titled “Traits”Traits hint to the iOS app how to present the action button:
| Trait | Meaning |
|---|---|
confirm | Requires confirmation before executing |
destructive | Shown in red — the action is irreversible |
auth_required | Requires device unlock before executing |
defer | Temporal — the action postpones something |
Fetch-mutate-PUT pattern
Section titled “Fetch-mutate-PUT pattern”Some APIs (like Vikunja) don’t support PATCH — to update one field, you must GET the full resource, modify it, and PUT it back. Action-level fetches handle this:
actions: snooze_30: title: Snooze 30m traits: [defer] params: minutes: 30 fetches: - name: task method: GET url: "${VIKUNJA_URL}/api/v1/tasks/{{ state.task_id }}" headers: Authorization: "Bearer ${VIKUNJA_TOKEN}" request: | { method: "POST", url: "${VIKUNJA_URL}/api/v1/tasks/" + string(state.task_id), headers: { Authorization: "Bearer ${VIKUNJA_TOKEN}", "Content-Type": "application/json" }, body: merge(fetched.task.data, { reminders: map(fetched.task.data.reminders, #.reminder == state.reminder_time ? merge(#, { reminder: rfc3339(addMinutes(now, action.minutes)) }) : # ) }) }The fetches run before the request expression is evaluated. The response is available as fetched.{name} — here, fetched.task.data is the full task object from the GET.
Fetches
Section titled “Fetches”Fetches are HTTP requests that run before expressions are evaluated, making their responses available as context. They exist at two levels:
Webhook-level fetches (shared)
Section titled “Webhook-level fetches (shared)”Defined under webhook.fetches, these run once per incoming webhook and are available to all notification and action evaluations:
webhook: fetches: - name: chore method: GET url: "${DONETICK_URL}/api/v1/chores/{{ payload.data.id }}" headers: secretkey: "${DONETICK_API_KEY}" optional: trueUse optional: true when the fetch might fail (e.g. the resource was deleted) — the notification will still be sent, and fetched.chore will be nil.
The response is available as fetched.chore.data in all expressions.
Action-level fetches
Section titled “Action-level fetches”Defined under each action’s fetches, these run only when that specific action is executed:
actions: snooze_30: fetches: - name: task method: GET url: "${VIKUNJA_URL}/api/v1/tasks/{{ state.task_id }}" headers: Authorization: "Bearer ${VIKUNJA_TOKEN}" request: | { ... fetched.task.data ... }Action-level fetches are ideal for the fetch-mutate-PUT pattern — you only hit the upstream API when the user actually taps the action, not on every webhook.
Fetch URL templates
Section titled “Fetch URL templates”Fetch URLs use {{ }} template syntax (not the expression language) for variable interpolation. Available variables depend on context:
| Context | Available variables |
|---|---|
| Webhook-level fetches | payload, vars |
| Action-level fetches | state, action, vars |
Error handling
Section titled “Error handling”When a webhook or action fails, the agent can send an error notification to a TipOff user. Define handlers in the errors block:
errors: webhook: body: | { to: vars.fallback_user, id: "donetick-agent-error", title: "Donetick reminder failed", body: error.phase == "call" ? "TipOff API returned " + string(error.step) : "Processing failed at " + error.phase + ": " + error.message, priority: "low", state: { adapter_id: "donetick" } } action: body: | { to: user, id: "donetick-action-error", title: "Couldn't " + action.label + " on Donetick", body: error.phase == "call" ? "Donetick returned " + string(error.step) : "Processing failed: " + error.message, priority: "low", state: { adapter_id: "donetick" } }Error context variables:
error.phase— where it failed ("match","eval","call","fetch")error.message— the error messageerror.step— HTTP status code (whenphase == "call")user— the TipOff user ID (action errors only)action.label— the action title (action errors only)
If no adapter-level error handler is defined, the agent falls back to TIPOFF_AGENT_ERROR_TARGET (if set).
Variables
Section titled “Variables”Adapters have two ways to parameterise values: environment variables for secrets and per-deployment config, and vars for adapter-scoped constants used in expressions.
Environment variables (${...})
Section titled “Environment variables (${...})”${ENV_VAR} pulls a value from the agent process’s environment. Use it for secrets, base URLs, and anything that changes between deployments:
DONETICK_URL=https://donetick.local \DONETICK_API_KEY=dt_key_here \DONETICK_WEBHOOK_SECRET=my-webhook-secret \./tipoff-agentwebhook: auth: bearer: secret: "${DONETICK_WEBHOOK_SECRET}"
actions: done: request: | { method: "POST", url: "${DONETICK_URL}/api/v1/chores/" + string(state.task_id) + "/do", headers: { secretkey: "${DONETICK_API_KEY}" } }${...} works anywhere in the YAML — in auth, fetches, vars, request, etc.
Adapter variables (vars)
Section titled “Adapter variables (vars)”The vars block defines named values that are available as vars.* in expressions. Use it for mappings and constants that don’t belong in environment variables:
vars: base_url: "${DONETICK_URL}" fallback_user: "user_abc" users: "1": "user_abc" "2": "user_def"Reference them in expressions:
to: vars.users[string(payload.data.user.id)]click_url: vars.base_url + "/chores/" + idto: vars.fallback_uservars can themselves use ${...} — in the example above, vars.base_url resolves to the value of DONETICK_URL. This keeps the environment variable reference in one place rather than scattered across every expression.
Expression language
Section titled “Expression language”Adapter expressions use expr-lang — a safe, sandboxed expression evaluator. Expressions appear in body, request, and if fields as multi-line strings.
Context variables
Section titled “Context variables”| Variable | Available in | Description |
|---|---|---|
payload | Notifications, webhook fetches | Raw webhook request body (parsed JSON) |
vars | Everywhere | Adapter vars block |
fetched | Notifications (after webhook fetches), actions (after action fetches) | Fetch responses — fetched.{name}.data for the parsed body |
id | Notification body | The evaluated notification ID |
state | Action request, action fetches | The state object from the notification that triggered this action |
action | Action request | Action metadata — action.minutes, action.label, etc. (from params) |
now | Everywhere | Current time (time.Time) |
user | Action error handler | TipOff user ID of the user who tapped the action |
error | Error handlers | Error context (error.phase, error.message, error.step) |
Built-in functions
Section titled “Built-in functions”Time:
| Function | Signature | Example |
|---|---|---|
now | () -> Time | now |
addMinutes(t, n) | (Time, int) -> Time | addMinutes(now, 30) |
addHours(t, n) | (Time, int) -> Time | addHours(now, 1) |
addSeconds(t, n) | (Time, int) -> Time | addSeconds(now, 90) |
rfc3339(t) | (Time) -> string | rfc3339(addMinutes(now, action.minutes)) |
Object manipulation:
| Function | Signature | Example |
|---|---|---|
merge(a, b) | (map, map) -> map | merge(fetched.task.data, { done: true }) |
deepMerge(a, b) | (map, map) -> map | deepMerge(config, overrides) |
lookup(m, key) | (map, string) -> any | lookup(payload.data, "optional_field") |
JSON:
| Function | Signature | Example |
|---|---|---|
toJSON(v) | (any) -> string | toJSON(payload) |
fromJSON(s) | (string) -> any | fromJSON(raw_string) |
Built-in expr-lang:
Standard expr-lang operators and functions are also available: string(), int(), float(), ternary (a ? b : c), nil checks (!= nil), array access, map(), filter(), len(), string concatenation (+), comparison operators.
Full adapter reference
Section titled “Full adapter reference”For the complete JSON Schema (validated at load time), see the adapter schema published to the contract registry as tipoff-adapter-schema.
Top-level fields
Section titled “Top-level fields”| Field | Required | Description |
|---|---|---|
owner | Yes | TipOff user ID that owns this adapter |
webhook | Yes | Webhook configuration (auth, fetches, notifications) |
actions | Yes | Map of action ID to action definition |
errors | No | Error notification handlers |
vars | No | Adapter-scoped variables |
Action fields
Section titled “Action fields”| Field | Required | Description |
|---|---|---|
title | Yes | Button label shown on the iOS notification |
traits | No | Array of "confirm" and/or "defer" |
params | No | Static parameters available as action.* in the request expression |
fetches | No | HTTP requests to run before evaluating the request expression |
request | Yes | Expression that produces { method, url, headers?, body? } |