Skip to content

Hooks & Tasks

Unified automation system. Hooks react to events (messages, agent completions, webhooks) or run on cron schedules (tasks). Both are stored in the same hooks table — a “task” is simply a hook with a schedule trigger.

A hook has three parts:

  1. Trigger — what event fires the hook
  2. Conditions (optional) — filter rules that must match
  3. Action — what to do when triggered
TriggerFires whenConfig fields
message_receivedNew message on any channelchannelTypes[], messagePatterns[] (regex), sessionFilter
agent_startedAgent begins worksessionFilter.topics[], sessionFilter.userIds[]
agent_completedAgent finishes successfullysame as above
agent_failedAgent errors outsame as above
tool_executedA tool runstoolIds[], toolNames[]
permission_requestedTool needs user approval—
scheduleCron timer firescronExpression, timezone
webhookInbound HTTP POST to /api/hooks/incoming/:hookIdwebhookSecret, messageTemplate
ActionWhat it doesConfig fields
notifySend message to channel(s)notifyChannels[], notifyMessage, channelType, channelId
spawn_agentStart an AI agentagentPrompt, agentTopic, agentModel, orchestrated
webhookSend outgoing HTTP requestwebhookUrl, webhookMethod, webhookHeaders, webhookBody
n8n_workflowTrigger N8N workflowworkflowId, workflowData
execute_toolRun a registered tooltoolId, toolAction, toolParams

notifyChannels uses format type:channelId:

telegram:123456789 # Telegram chat ID
slack:C0123ABCDEF # Slack channel ID
webchat:<your-user-id> # Your own web chat (all your open web chat connections)
teams:channel-id # Microsoft Teams conversation

The type is split on the first colon, so Teams ids that contain colons work as-is. actionConfig.notifyOwner: true (“Notify me”) sends to every external chat linked to the hook owner instead of naming targets.

To find your Telegram chat ID: send any message to the bot and check backend logs (~/.octipus/backend.log).

Not every chat id is accepted: see Who a hook may notify.

Hooks, scheduled tasks, monitors, notifications and agent tools send on behalf of a user, so the bot only messages chats that user may notify. The rule lives in src/channels/ownership.ts and is applied to every outbound send: a notify hook’s notifyChannels / channelType + channelId, a notification’s deliverTo, a monitor’s reply, and the messaging tool.

A target (channelType, channelId) is allowed when it is one of:

  • Your own linked chat, always. A chat verified to your account (see Account Linking), or your 1:1 conversation with the bot on that identity: a Teams personal chat or a Slack DM (D…) whose user is you.
  • An admin-approved shared destination. Shared channels and groups (a Slack #alerts channel, a Telegram group, a Teams channel or group chat) are allowed only when an admin has listed them under Admin → Notification destinations, either for everyone or for an org you are a member of.

Anything else is refused with: <type>:<id> is not linked to you and not an approved shared destination; ask an admin to add it under Admin → Notification destinations. Only telegram, slack, teams and whatsapp have outbound chat ids; any other channel type (apart from webchat / api, below) is never a target.

When targets are checked.

  • On save: POST /api/hooks, PATCH /api/hooks/:id, applying a hook suggestion, and POST /api/recurring-tasks return 400 naming each rejected target. An edit only checks targets that are new compared with the stored config, so a hook already holding a now-invalid target stays editable.
  • On send: a notify hook skips each target that is not allowed and logs a warning with the hook id; the other targets still receive the message. If no target is left, the execution fails with the refusal message. Removing a destination in the admin page therefore makes hooks that target it stop sending there.

Existing hooks with targets that are no longer allowed (saved before this check, or whose chat was unlinked or whose destination was removed):

  • GET /api/hooks and GET /api/hooks/:id return invalidTargets, a list of type:id strings. The hooks page shows it as an “N invalid target(s)” badge; its tooltip lists the targets that are skipped when the hook runs.
  • At startup the server logs one warning with, per user, the number of affected hooks and targets (Hooks with notification targets their owner may no longer send to ...). Only notify and execute_tool hooks are scanned.

To fix a flagged hook, replace the target with one of your own linked chats, or ask an admin to approve it.

Web chat and API targets. webchat and api address the in-app surfaces. The only valid target is your own user id, webchat:<your-user-id>, which is delivered to all of your live web chat connections. Raw web chat connection ids and any other user’s id are refused. Migration 0117_hook_inapp_targets rewrote stored webchat:<connection id> and api:<anything> targets (in notifyChannels and in channelType + channelId) to webchat:<owner id>.

Teams. A proactive Teams send needs a conversation reference, which Teams only provides when a message arrives from that conversation. References are persisted (kv_store, key teams:convref:<id>) and reloaded after a restart. A Teams target the bot has never seen resolves as “unresolved” rather than “not linked”: the Teams conversation cannot be resolved yet; the user must message the bot in Teams once. Your own Teams identity (the id stored when you linked Teams) is delivered to your 1:1 conversation(s) with the bot. Teams channels and group chats need an approved destination.

Monitors and sessions. A monitor replies to its session’s chat only if the monitor’s owner may notify that chat; otherwise nothing is sent and the delivery fails with the same error. POST /api/sessions only creates a session on an external channel (telegram, slack, teams, whatsapp) whose chat the caller may notify; other session types are accepted as before. PATCH /api/sessions/:id only updates title, status, context and metadata, so a session’s chat cannot be changed after creation.

Admins manage the allowlist at Admin → Notification destinations (/admin/destinations):

  1. Channel: telegram, slack, teams or whatsapp.
  2. Channel / chat id: the id exactly as a hook would name it after the colon, e.g. C0123ALERTS for slack:C0123ALERTS.
  3. Label (optional): a display name such as #alerts.
  4. For: everyone approves the destination for every user on the instance (stored with no org). Choosing an org approves it only for that org’s members. The org list appears only when organizations are enabled; otherwise everyone is the only choice.

The same (channel, id, org) can be listed once; adding it again returns 409. The trash icon removes a destination. Additions and removals are recorded in the audit log (settings_changed, resource type notification_destination).

The page uses these admin-only endpoints (stored in the notification_destinations table, migration 0116):

MethodPathDescription
GET/api/admin/notification-destinationsList destinations and the allowed channelTypes
POST/api/admin/notification-destinationsAdd { channelType, channelId, label?, orgId? }; orgId omitted or null means everyone. 400 for an unknown channel type, 404 for an unknown org, 409 if already listed
DELETE/api/admin/notification-destinations/:idRemove a destination

Messaging tool: send_message and send_to_user

Section titled “Messaging tool: send_message and send_to_user”

The same rule applies when an agent (or an execute_tool hook or task) uses the messaging tool:

  • send_message (channel, target, message): the target must be one of your own linked chats, webchat:<your-user-id>, or an approved shared destination. This is enforced on every call, attended or not; approving the tool’s permission prompt (or “always allow”) does not widen it.
  • send_to_user (user_id, message, optional channel): sends to the recipient’s own verified linked chats. You may target only yourself. An admin may target another user only from an attended run that can prompt a human; unattended runs (cron, heartbeat, message hooks, execute_tool) may only target the calling user, admin or not.

When an execute_tool hook or a recurring task names the messaging tool with literal values, they are checked at save time: a send_message channel + target must pass the rule above, and a send_to_user user_id must be your own id (A send_to_user hook can only message you). Values containing {{...}} templates are only known at run time and are checked by the tool when it runs.

Use {{path.to.value}} in notifyMessage, agentPrompt, and webhookBody:

VariableAvailable in triggers
{{message.content}}message_received
{{message.channelType}}message_received
{{message.channelId}}message_received
{{message.userId}}message_received
{{agent.id}}agent_started, agent_completed, agent_failed
{{agent.sessionId}}agent_started, agent_completed, agent_failed
{{agent.topic}}agent_started, agent_completed, agent_failed
{{agent.status}}agent_completed, agent_failed
{{tool.name}}tool_executed
{{tool.toolId}}tool_executed
{{webhook.path}}webhook
{{webhook.method}}webhook
{{webhook.body.*}}webhook (nested fields)
{{schedule.cronExpression}}schedule
{{schedule.scheduledTime}}schedule

Optional array of rules that ALL must match for the hook to fire:

{
"conditions": [
{ "field": "message.content", "operator": "contains", "value": "deploy" },
{ "field": "message.channelType", "operator": "equals", "value": "telegram" }
]
}

Operators: equals, contains, matches (regex), gt, lt, in (array).

FieldPurpose
isEnabledToggle hook on/off
priorityHigher priority hooks run first (default: 0)
maxExecutionsStop after N runs (null = unlimited)
cooldownMsMinimum ms between executions (default: 0)

GitHub push handler via inbound webhook:

{
"name": "GitHub Push Handler",
"trigger": "webhook",
"triggerConfig": {
"webhookSecret": "my-secret-123",
"messageTemplate": "New push to {{body.repository.name}} by {{body.pusher.name}}: {{body.head_commit.message}}"
},
"action": "notify",
"actionConfig": {
"channelType": "telegram",
"channelId": "123456789"
}
}

Notify on agent failure:

{
"name": "Error alert",
"trigger": "agent_failed",
"triggerConfig": {},
"action": "notify",
"actionConfig": {
"notifyChannels": ["telegram:123456789"],
"notifyMessage": "Agent failed on topic '{{agent.topic}}'"
}
}

Scheduled daily check:

{
"name": "Morning email check",
"trigger": "schedule",
"triggerConfig": { "cronExpression": "0 9 * * *", "timezone": "Europe/Berlin" },
"action": "spawn_agent",
"actionConfig": {
"agentPrompt": "Check Gmail for unread emails and summarize.",
"orchestrated": true
}
}

External services can trigger hooks by POSTing to /api/hooks/incoming/:hookId. This enables GitHub, Stripe, smart home sensors, n8n, and any HTTP-capable service to drive agent actions.

  1. Create a hook with trigger: "webhook" via the API or UI
  2. Set triggerConfig.webhookSecret for authentication
  3. Optionally set triggerConfig.messageTemplate for payload transformation
  4. Configure the external service to POST to https://your-host/api/hooks/incoming/<hook-uuid>

Every request must include the webhook secret via one of:

  • Authorization: Bearer <secret> header
  • X-Webhook-Secret: <secret> header

Requests without valid secrets are rejected with 401.

Use Mustache-style {{path.to.value}} syntax to transform incoming JSON payloads into agent prompts:

{
"messageTemplate": "Push to {{body.repository.name}} by {{body.pusher.name}}: {{body.head_commit.message}}"
}

If no template is provided, the raw JSON payload is forwarded to the agent.

Results can be routed to a channel via actionConfig:

  • channelType + channelId — direct delivery (e.g., telegram + 123456789)
  • notifyChannels[] — standard multi-channel delivery

Both follow Who a hook may notify.

Terminal window
# 1. Create the webhook hook
curl -X POST https://your-host/api/hooks \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "GitHub Push Alert",
"trigger": "webhook",
"triggerConfig": {
"webhookSecret": "gh-secret-123",
"messageTemplate": "Push to {{body.repository.name}} by {{body.pusher.name}}"
},
"action": "notify",
"actionConfig": { "channelType": "telegram", "channelId": "123456789" }
}'
# 2. Configure GitHub webhook to POST to:
# https://your-host/api/hooks/incoming/<returned-hook-uuid>
# with secret: gh-secret-123

A scheduled task is a hook with trigger: "schedule". The cron runner checks every 60 seconds for due hooks and executes them. In the UI, these appear under the “Scheduled Tasks” tab.

Recurring (cron): Runs on a repeating schedule using cron expressions. The schedule picker offers presets, interval, daily, weekly, and raw cron modes.

One-time (datetime): Runs exactly once at a specific date and time, then auto-disables. Set triggerConfig.scheduledAt instead of cronExpression. In the UI, use the “Date & Time” tab in the schedule picker.

The Calendar tab in the Hooks & Tasks page shows a weekly grid of scheduled tasks:

  • Recurring tasks appear on their next run day (green)
  • Datetime tasks appear on their exact scheduled day (blue)
  • Navigate weeks with arrow buttons, “Today” resets to current week
  • Server timezone is displayed for reference
FieldPurpose
triggerConfig.cronExpressionCron schedule (for recurring tasks)
triggerConfig.scheduledAtISO 8601 datetime (for one-time tasks)
triggerConfig.timezoneTimezone (default: UTC)
nextRunAtComputed next execution time
lastErrorLast execution error (null on success)
* * * * *
│ │ │ │ │
│ │ │ │ └── Day of week
│ │ │ └──── Month
│ │ └────── Day of month
│ └──────── Hour
└────────── Minute
*/5 * * * * Every 5 minutes
0 * * * * Every hour at :00
0 9 * * * Daily at 9:00 AM
0 */2 * * * Every 2 hours
0 9 * * 1-5 Weekdays at 9:00 AM

A spawn_agent hook may set actionConfig.awayDigestHours (a positive number). Before the run, the owner’s “while you were away” digest for that many hours (src/core/digest/away.ts: finished and failed agents, pipelines finished or waiting, pending approvals, new to-dos, unread count) is rendered as markdown and prepended to agentPrompt. No model call is spent on it, and a digest that cannot be built is logged and skipped rather than failing the hook.

Every user gets one scheduled hook at registration, Daily Briefing: weekdays at 08:00 (UTC by default), an orchestrated spawn_agent turn whose result is delivered to the user. The prompt is integration-agnostic — it always reads the to-do list and notifications, and only reads calendar / mail / GitHub when those tools are connected, finishing with the three actions to take first. It is an ordinary hook: edit the schedule or timezone, pause it, or delete it on the Hooks page. It costs one agent turn per weekday.

Users who registered before it existed, or who paused it, can bring it back:

Terminal window
curl -X POST localhost:3005/api/hooks/briefing -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' -d '{"timezone":"Europe/Berlin"}'
curl -X DELETE localhost:3005/api/hooks/briefing -H "Authorization: Bearer $TOKEN" # pause

Code: src/core/briefing.ts. The heartbeat (HEARTBEAT.md) is the complementary loop — silent unless something is pending, off by default.

StatusCondition
activeisEnabled and no lastError
paused!isEnabled
errorlastError is set — check execution log for details

Every hook and recurring task execution is recorded in the hook_executions table. View logs in the UI under Hooks > Execution Log tab.

Each log entry contains:

  • source: hook or manual_test
  • status: success, error, or skipped
  • triggerType / actionType: what fired and what ran
  • result: full action output (JSON)
  • error: error message if failed
  • durationMs: execution time
  • triggerContext: sanitized snapshot of what triggered the hook
  1. Go to Hooks page > click the history icon on the hook row
  2. Or switch to “Execution Log” tab for all executions
  3. Expand an entry to see full result/error and trigger context
  4. If no executions appear, verify:
    • Hook is enabled
    • Trigger config matches (channel types, patterns, webhook path)
    • Conditions aren’t filtering everything out
    • Cooldown hasn’t blocked it
    • Max executions hasn’t been reached
MethodPathDescription
GET/api/hooksList user’s hooks
POST/api/hooksCreate hook
GET/api/hooks/:idGet hook
PATCH/api/hooks/:idUpdate hook
DELETE/api/hooks/:idDelete hook
POST/api/hooks/:id/toggleEnable/disable
POST/api/hooks/:id/testTrigger manually
GET/api/hooks/:id/executionsExecution history for hook
GET/api/hooks/executions/allAll user executions
GET/api/hooks/suggestionsSuggested hooks
POST/api/hooks/suggestions/:suggestionId/applyApply suggestion
POST/api/hooks/briefingEnsure (create / re-enable) the seeded Daily Briefing; optional timezone, cronExpression
DELETE/api/hooks/briefingPause the Daily Briefing

The /api/recurring-tasks endpoints still work as a compatibility layer — they map to schedule-triggered hooks internally.

MethodPathDescription
GET/api/recurring-tasksList schedule hooks
POST/api/recurring-tasksCreate schedule hook
GET/api/recurring-tasks/:idGet schedule hook
PATCH/api/recurring-tasks/:idUpdate schedule hook
GET/api/recurring-tasks/:id/executionsExecution history
DELETE/api/recurring-tasks/:idDelete schedule hook

The swarm root agent (Root agent → Agent → Subagent) publishes its own gateway event family (swarm.*) alongside the older agent.* events. These are pure observability signals — they are not hook triggers today (the hook system matches on agent_* triggers, which still fire for every swarm node because every node is also an agent). If you need to react to swarm-specific state — fan-out breach, cycle blocks, budget warnings — subscribe on the gateway instead of the hook system.

EventWhen it fires
swarm.node_spawnedA new Root agent / Agent / Subagent is created. Payload includes rootSessionId, nodeId, parentNodeId, kind, depth, topicPath, role, model, budgets, and taskBriefPreview. The legacy expertId field may still be present for wire compatibility but is currently null after the expert layer was retired.
swarm.node_completedNode finished; ChildResult is attached (status + output + usedTokens + durationMs).
swarm.budget_warningNode crossed its budget warning threshold.
swarm.call_graph_cycle_blockedDuplicate / ancestor-chain fingerprint rejected a spawn.

All swarm.* events participate in the gateway replay buffer (swarm.* pattern); subscribers can catch up after reconnect.

Wake-gate: scheduled hooks can carry a wakeGate (command / http / tool) evaluated just-before-run. A failing gate emits skipped_by_wakegate and skips execution.


Edit this page in the repository.