Skip to content

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.


Each adapter defines how to verify incoming webhooks in the webhook.auth block. Three composable layers are available — use one or combine them.

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.

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.

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: 5m

The header value can be RFC3339 or Unix epoch. Both formats are accepted.

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: 5m

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).

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 the actions: section). These become the interactive buttons on the iOS notification.
  • state: Opaque metadata that round-trips through the device and back. Available as state in 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.

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.


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.

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}" }
}

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 hint to the iOS app how to present the action button:

TraitMeaning
confirmRequires confirmation before executing
destructiveShown in red — the action is irreversible
auth_requiredRequires device unlock before executing
deferTemporal — the action postpones something

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 are HTTP requests that run before expressions are evaluated, making their responses available as context. They exist at two levels:

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: true

Use 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.

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 URLs use {{ }} template syntax (not the expression language) for variable interpolation. Available variables depend on context:

ContextAvailable variables
Webhook-level fetchespayload, vars
Action-level fetchesstate, action, vars

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 message
  • error.step — HTTP status code (when phase == "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).


Adapters have two ways to parameterise values: environment variables for secrets and per-deployment config, and vars for adapter-scoped constants used in expressions.

${ENV_VAR} pulls a value from the agent process’s environment. Use it for secrets, base URLs, and anything that changes between deployments:

Terminal window
DONETICK_URL=https://donetick.local \
DONETICK_API_KEY=dt_key_here \
DONETICK_WEBHOOK_SECRET=my-webhook-secret \
./tipoff-agent
webhook:
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.

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/" + id
to: vars.fallback_user

vars 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.


Adapter expressions use expr-lang — a safe, sandboxed expression evaluator. Expressions appear in body, request, and if fields as multi-line strings.

VariableAvailable inDescription
payloadNotifications, webhook fetchesRaw webhook request body (parsed JSON)
varsEverywhereAdapter vars block
fetchedNotifications (after webhook fetches), actions (after action fetches)Fetch responses — fetched.{name}.data for the parsed body
idNotification bodyThe evaluated notification ID
stateAction request, action fetchesThe state object from the notification that triggered this action
actionAction requestAction metadata — action.minutes, action.label, etc. (from params)
nowEverywhereCurrent time (time.Time)
userAction error handlerTipOff user ID of the user who tapped the action
errorError handlersError context (error.phase, error.message, error.step)

Time:

FunctionSignatureExample
now() -> Timenow
addMinutes(t, n)(Time, int) -> TimeaddMinutes(now, 30)
addHours(t, n)(Time, int) -> TimeaddHours(now, 1)
addSeconds(t, n)(Time, int) -> TimeaddSeconds(now, 90)
rfc3339(t)(Time) -> stringrfc3339(addMinutes(now, action.minutes))

Object manipulation:

FunctionSignatureExample
merge(a, b)(map, map) -> mapmerge(fetched.task.data, { done: true })
deepMerge(a, b)(map, map) -> mapdeepMerge(config, overrides)
lookup(m, key)(map, string) -> anylookup(payload.data, "optional_field")

JSON:

FunctionSignatureExample
toJSON(v)(any) -> stringtoJSON(payload)
fromJSON(s)(string) -> anyfromJSON(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.


For the complete JSON Schema (validated at load time), see the adapter schema published to the contract registry as tipoff-adapter-schema.

FieldRequiredDescription
ownerYesTipOff user ID that owns this adapter
webhookYesWebhook configuration (auth, fetches, notifications)
actionsYesMap of action ID to action definition
errorsNoError notification handlers
varsNoAdapter-scoped variables
FieldRequiredDescription
titleYesButton label shown on the iOS notification
traitsNoArray of "confirm" and/or "defer"
paramsNoStatic parameters available as action.* in the request expression
fetchesNoHTTP requests to run before evaluating the request expression
requestYesExpression that produces { method, url, headers?, body? }