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.
Concepts
Section titled “Concepts”A hook has three parts:
- Trigger — what event fires the hook
- Conditions (optional) — filter rules that must match
- Action — what to do when triggered
Triggers
Section titled “Triggers”| Trigger | Fires when | Config fields |
|---|---|---|
message_received | New message on any channel | channelTypes[], messagePatterns[] (regex), sessionFilter |
agent_started | Agent begins work | sessionFilter.topics[], sessionFilter.userIds[] |
agent_completed | Agent finishes successfully | same as above |
agent_failed | Agent errors out | same as above |
tool_executed | A tool runs | toolIds[], toolNames[] |
permission_requested | Tool needs user approval | — |
schedule | Cron timer fires | cronExpression, timezone |
webhook | Inbound HTTP POST to /api/hooks/incoming/:hookId | webhookSecret, messageTemplate |
Actions
Section titled “Actions”| Action | What it does | Config fields |
|---|---|---|
notify | Send message to channel(s) | notifyChannels[], notifyMessage, channelType, channelId |
spawn_agent | Start an AI agent | agentPrompt, agentTopic, agentModel, orchestrated |
webhook | Send outgoing HTTP request | webhookUrl, webhookMethod, webhookHeaders, webhookBody |
n8n_workflow | Trigger N8N workflow | workflowId, workflowData |
execute_tool | Run a registered tool | toolId, toolAction, toolParams |
Notify Action — Channel Format
Section titled “Notify Action — Channel Format”notifyChannels uses format type:channelId:
telegram:123456789 # Telegram chat IDslack:C0123ABCDEF # Slack channel IDwebchat:<your-user-id> # Your own web chat (all your open web chat connections)teams:channel-id # Microsoft Teams conversationThe 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.
Who a hook may notify
Section titled “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
#alertschannel, 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, andPOST /api/recurring-tasksreturn400naming 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/hooksandGET /api/hooks/:idreturninvalidTargets, a list oftype:idstrings. 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 ...). Onlynotifyandexecute_toolhooks 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.
Adding a shared destination
Section titled “Adding a shared destination”Admins manage the allowlist at Admin → Notification destinations (/admin/destinations):
- Channel:
telegram,slack,teamsorwhatsapp. - Channel / chat id: the id exactly as a hook would name it after the colon, e.g.
C0123ALERTSforslack:C0123ALERTS. - Label (optional): a display name such as
#alerts. - For:
everyoneapproves 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; otherwiseeveryoneis 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):
| Method | Path | Description |
|---|---|---|
| GET | /api/admin/notification-destinations | List destinations and the allowed channelTypes |
| POST | /api/admin/notification-destinations | Add { 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/:id | Remove 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, optionalchannel): 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.
Template Variables
Section titled “Template Variables”Use {{path.to.value}} in notifyMessage, agentPrompt, and webhookBody:
| Variable | Available 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 |
Conditions
Section titled “Conditions”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).
Execution Control
Section titled “Execution Control”| Field | Purpose |
|---|---|
isEnabled | Toggle hook on/off |
priority | Higher priority hooks run first (default: 0) |
maxExecutions | Stop after N runs (null = unlimited) |
cooldownMs | Minimum ms between executions (default: 0) |
Example Configurations
Section titled “Example Configurations”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 }}Inbound Webhooks
Section titled “Inbound Webhooks”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.
- Create a hook with
trigger: "webhook"via the API or UI - Set
triggerConfig.webhookSecretfor authentication - Optionally set
triggerConfig.messageTemplatefor payload transformation - Configure the external service to POST to
https://your-host/api/hooks/incoming/<hook-uuid>
Authentication
Section titled “Authentication”Every request must include the webhook secret via one of:
Authorization: Bearer <secret>headerX-Webhook-Secret: <secret>header
Requests without valid secrets are rejected with 401.
Payload Templating
Section titled “Payload Templating”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.
Delivery
Section titled “Delivery”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.
Example: GitHub → Telegram
Section titled “Example: GitHub → Telegram”# 1. Create the webhook hookcurl -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-123Scheduled Tasks
Section titled “Scheduled Tasks”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.
Schedule Types
Section titled “Schedule Types”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.
Calendar View
Section titled “Calendar View”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
Schedule-Specific Fields
Section titled “Schedule-Specific Fields”| Field | Purpose |
|---|---|
triggerConfig.cronExpression | Cron schedule (for recurring tasks) |
triggerConfig.scheduledAt | ISO 8601 datetime (for one-time tasks) |
triggerConfig.timezone | Timezone (default: UTC) |
nextRunAt | Computed next execution time |
lastError | Last execution error (null on success) |
Cron Expression Format
Section titled “Cron Expression Format”* * * * *│ │ │ │ ││ │ │ │ └── Day of week│ │ │ └──── Month│ │ └────── Day of month│ └──────── Hour└────────── Minute
*/5 * * * * Every 5 minutes0 * * * * Every hour at :000 9 * * * Daily at 9:00 AM0 */2 * * * Every 2 hours0 9 * * 1-5 Weekdays at 9:00 AMAway digest in a hook prompt
Section titled “Away digest in a hook prompt”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.
Daily Briefing (seeded)
Section titled “Daily Briefing (seeded)”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:
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" # pauseCode: src/core/briefing.ts. The heartbeat (HEARTBEAT.md) is
the complementary loop — silent unless something is pending, off by default.
Task Status (derived)
Section titled “Task Status (derived)”| Status | Condition |
|---|---|
active | isEnabled and no lastError |
paused | !isEnabled |
error | lastError is set — check execution log for details |
Execution Log
Section titled “Execution Log”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:
hookormanual_test - status:
success,error, orskipped - 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
Debugging Hooks
Section titled “Debugging Hooks”- Go to Hooks page > click the history icon on the hook row
- Or switch to “Execution Log” tab for all executions
- Expand an entry to see full result/error and trigger context
- 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
API Endpoints
Section titled “API Endpoints”| Method | Path | Description |
|---|---|---|
| GET | /api/hooks | List user’s hooks |
| POST | /api/hooks | Create hook |
| GET | /api/hooks/:id | Get hook |
| PATCH | /api/hooks/:id | Update hook |
| DELETE | /api/hooks/:id | Delete hook |
| POST | /api/hooks/:id/toggle | Enable/disable |
| POST | /api/hooks/:id/test | Trigger manually |
| GET | /api/hooks/:id/executions | Execution history for hook |
| GET | /api/hooks/executions/all | All user executions |
| GET | /api/hooks/suggestions | Suggested hooks |
| POST | /api/hooks/suggestions/:suggestionId/apply | Apply suggestion |
| POST | /api/hooks/briefing | Ensure (create / re-enable) the seeded Daily Briefing; optional timezone, cronExpression |
| DELETE | /api/hooks/briefing | Pause the Daily Briefing |
Recurring Tasks (compatibility)
Section titled “Recurring Tasks (compatibility)”The /api/recurring-tasks endpoints still work as a compatibility layer — they map to schedule-triggered hooks internally.
| Method | Path | Description |
|---|---|---|
| GET | /api/recurring-tasks | List schedule hooks |
| POST | /api/recurring-tasks | Create schedule hook |
| GET | /api/recurring-tasks/:id | Get schedule hook |
| PATCH | /api/recurring-tasks/:id | Update schedule hook |
| GET | /api/recurring-tasks/:id/executions | Execution history |
| DELETE | /api/recurring-tasks/:id | Delete schedule hook |
Swarm Lifecycle Events
Section titled “Swarm Lifecycle Events”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.
| Event | When it fires |
|---|---|
swarm.node_spawned | A 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_completed | Node finished; ChildResult is attached (status + output + usedTokens + durationMs). |
swarm.budget_warning | Node crossed its budget warning threshold. |
swarm.call_graph_cycle_blocked | Duplicate / 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.