Skip to content
Codeberg

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

Auth is required by default. Adapters without any auth layer will fail to load. If your webhook source genuinely cannot provide authentication (e.g., a local script on a trusted network), explicitly opt in:

webhook:
auth:
unsigned: true

This loads the adapter with a warning. Ensure network-layer protection (Tailscale, VPN, internal-only) is in place.


When debugging adapter matching, you can control when the agent includes full request detail (headers + body) in its structured logs. The log_requests field acts as a severity threshold — each level includes everything above it:

never → on_error → on_filter → always
LevelWhat it captures
neverNo request detail in logs
on_errorAuth failures, expression errors, fetch failures, delivery failures
on_filterEverything in on_error + webhooks where no notifications[] entry matched
alwaysEverything — including successful dispatches

Set it per-adapter in the webhook block:

webhook:
log_requests: on_filter
notifications:
# ...

The agent-wide default is controlled by the TIPOFF_AGENT_LOG_REQUESTS environment variable (default: on_error). Per-adapter values override the global default.

When triggered, the log line includes request_method, request_headers (with secrets redacted), and request_body as structured fields — useful for seeing exactly what the source sent when a webhook doesn’t match.


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 pipeline fails, the agent always logs the error with full context (phase, message, HTTP status). This is usually sufficient — structured logs give you everything you need to diagnose issues.

For cases where you want failures to be visible as push notifications (e.g. during early development or for non-technical users who won’t check logs), the agent supports optional error notification handlers in the errors block:

errors:
webhook:
body: |
{
to: vars.fallback_user,
id: "myapp-agent-error",
title: "Webhook processing failed",
body: error.phase == "call"
? "API returned " + string(error.step)
: "Processing failed at " + error.phase + ": " + error.message,
priority: "low",
state: { adapter_id: "myapp" }
}
action:
body: |
{
to: user,
id: "myapp-action-error",
title: "Couldn't " + action.label,
body: error.phase == "call"
? "Upstream returned " + string(error.step)
: "Processing failed: " + error.message,
priority: "low",
state: { adapter_id: "myapp" }
}

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). If that’s also unset, errors are logged only.


When notification body expressions contain complex inline calculations — nested ternaries, nil-guard chains, repeated sub-expressions — you can extract them into named bindings with a let block. Each binding is evaluated sequentially; later bindings can reference earlier ones.

webhook:
notifications:
- if: payload.type == "task.reminder"
id: string(payload.data.id)
actions: [done, snooze_30]
let:
- name: is_overdue
expr: payload.data.type == "overdue"
- name: subtitle
expr: 'is_overdue ? "Overdue!" : payload.data.type == "due" ? "Due now" : "Upcoming"'
- name: priority
expr: 'is_overdue ? "high" : "normal"'
body: |
{
to: vars.users["1"],
title: payload.data.name,
subtitle: subtitle,
priority: priority,
state: { task_id: payload.data.id }
}

Each let binding is available as a bare name in the body expression — subtitle not let.subtitle. The naming convention: vars.* is prefixed because it’s adapter-wide static data defined elsewhere in the file; let bindings are flat because they’re local to the notification you’re already reading.

Actions support let with the same semantics — bindings are evaluated before the request expression:

actions:
snooze_30:
title: Snooze 30m
params:
minutes: 30
let:
- name: task_url
expr: '"https://api.example.com/tasks/" + string(state.task_id)'
- name: new_due
expr: rfc3339(addMinutes(now, action.minutes))
request: |
{
method: "PUT",
url: task_url,
body: { dueDate: new_due }
}
  • No forward references — a binding can only reference bindings defined above it. Forward references are caught at adapter load time.
  • No reserved name shadowing — binding names like payload, vars, fetched, now, state, action, user are rejected at load time.
  • No duplicate names — each name must be unique within a single let block.

When multiple actions share the same request logic and differ only in title and params (e.g. snooze_30, snooze_60, snooze_1d), use extends to inherit from a base action instead of copy-pasting:

actions:
snooze_base:
title: Snooze
traits: [defer]
request: |
{
method: "PUT",
url: "${API_URL}/tasks/" + string(state.task_id) + "/dueDate",
headers: { "Content-Type": "application/json" },
body: { dueDate: rfc3339(addMinutes(now, action.minutes)) }
}
snooze_30:
title: Snooze 30m
extends: snooze_base
params:
minutes: 30
snooze_60:
title: Snooze 1h
extends: snooze_base
params:
minutes: 60
snooze_1d:
title: Remind Tomorrow
extends: snooze_base
params:
minutes: 1440
  • title and params are never inherited — every action must declare its own title. Params are action-local.
  • fetches, request, traits, on_error are inherited if not declared on the extending action. Declaring any of these replaces the inherited value entirely (no deep merge).
  • let bindings are merged by name — if the base defines let bindings and the extending action also declares let, bindings with matching names are overridden in-place; new names are appended at the end.
  • Single-level only — the base action must not itself extend another action. Chains and circular references are rejected at load time.
  • Base actions can be excluded from actions lists — a base that exists solely to be extended doesn’t need to appear in any notification’s actions array.

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.


  • Adapter Schema — complete field reference for every adapter YAML field, type, and constraint
  • Example Adapters — annotated production adapters (Donetick, Vikunja) demonstrating auth, fetches, let, extends, signal mode, and fetch-mutate-PUT