bot0 Trigger System Architecture
The trigger system enables bot0 to react to real-world events — new emails, competitor website changes, social media mentions, price drops, deploy failures — and execute automated responses. Triggers are the event-driven counterpart to cron schedules: where schedules run on time, triggers run on change.
Core Concepts
What is a Trigger?
A trigger is a condition-action pair: when X happens, do Y. The "when" is the detection mechanism (how we know something changed), and the "do" is the prompt (what the agent should do about it).
┌─────────────────────────────────────────────────────────────────────────────┐
│ TRIGGER ANATOMY │
│ │
│ ┌─────────────────────┐ ┌──────────────────────┐ │
│ │ DETECTOR │ │ RESPONDER │ │
│ │ │ │ │ │
│ │ "When something │────▶│ "Do this when it │ │
│ │ changes" │ │ fires" │ │
│ │ │ │ │ │
│ │ • Composio webhook │ │ • Agent prompt │ │
│ │ • API poll + diff │ │ • Target daemon │ │
│ │ • DOM observation │ │ • Tool access │ │
│ │ • Custom script │ │ • Permission mode │ │
│ └─────────────────────┘ └──────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Every trigger — whether it's a native Composio webhook or a custom DOM scraper — produces the same output: an event that starts a new agent session on the target daemon.
Trigger Types — Priority Hierarchy
When the user wants to monitor something, the system chooses the cheapest, most reliable detection method:
┌─────────────────────────────────────────────────────────────────────────────┐
│ TRIGGER TYPE SELECTION (priority order) │
│ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ 1. COMPOSIO NATIVE TRIGGER │ │
│ │ Webhook-based, real-time, zero polling cost │ │
│ │ Example: Gmail new email, GitHub PR opened, Slack mention │ │
│ │ Detection: Composio pushes webhook → Bytespace → daemon │ │
│ │ Cost: $0 per fire │ │
│ │ Latency: ~seconds │ │
│ │ Setup: Single API call (bytespace_trigger create) │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │ │
│ │ Not supported? │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ 2. CRON + API POLLING │ │
│ │ Scheduled API calls (Composio or direct), diff stored state │ │
│ │ Example: Instagram new comments, Notion page changes │ │
│ │ Detection: Cron runs → API fetch → diff → fire if changed │ │
│ │ Cost: ~$0 per poll (API only, no LLM) │ │
│ │ Latency: cron interval (e.g., every 5 minutes) │ │
│ │ Setup: Agent writes detection script + creates schedule │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │ │
│ │ No API available? │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ 3. CRON + DOM0 (browser automation) │ │
│ │ Headless browser checks via Chrome DevTools Protocol │ │
│ │ Example: Competitor website updates, price monitoring │ │
│ │ Detection: Cron runs → dom0 snapshot → diff → fire │ │
│ │ Cost: ~$0 per poll (no LLM, deterministic DOM) │ │
│ │ Latency: cron interval │ │
│ │ Setup: Agent writes detection script + creates schedule │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │ │
│ │ No stable DOM structure? │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ 4. CRON + CMD0 / CUSTOM SCRIPT │ │
│ │ Visual grounding or arbitrary code for detection │ │
│ │ Example: Desktop app state, visual dashboards, RSS feeds │ │
│ │ Detection: Cron runs → script/cmd0 → diff → fire │ │
│ │ Cost: ~$0.01+ per poll (cmd0 uses grounding model) │ │
│ │ Latency: cron interval │ │
│ │ Setup: Agent writes detection script + creates schedule │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Type 1 (Composio native) is always preferred — it's real-time, zero-cost, and requires no custom code.
Types 2-4 (custom) share the same pattern: a cron schedule runs a detection script that diffs current state against a checkpoint. The agent builds these using existing tools — no special framework needed.
Design Principles
-
Native first — Always check if Composio has a native trigger before building a custom one.
-
Deterministic detection, always — Detection scripts never use LLM. Detection is a programmatic diff: "is the current state different from the stored state?" The LLM only runs in the response phase, after the trigger has already fired. This keeps polling cost at $0 and is enforced structurally (see Enforcing No-LLM Detection).
-
One table — All triggers (native + custom) live in
ctx0_triggers. One source of truth. -
State on the row — Each trigger's checkpoint state is a JSONB column on the trigger record itself. No separate state table, no local files. State persists across daemon restarts and daemon migrations.
-
Powered by schedules — Custom triggers are cron schedules with a detection script. The existing schedule infrastructure handles timing, daemon targeting, and execution. Triggers add change detection on top.
-
No special builder mode — The agent builds custom triggers using existing tools: write a script, test it, create a schedule. No
trigger_builderagent mode, no TRIGGER.md manifest format. The agent's normal coding abilities are sufficient. -
Test before deploy — Before a custom trigger goes live, the agent dry-runs the detection script against real data and shows what would fire. The user confirms before deployment.
Single Trigger Registry
The Problem with Two Tables
The current implementation has ctx0_composio_triggers for native Composio triggers. Adding a second table for custom triggers creates confusion: which table do you query to "list all triggers"? Which is the source of truth for a trigger that started as custom but was later replaced with a native one?
The Solution: One Unified Table
All triggers — native Composio webhooks, cron-based API pollers, dom0 scrapers, custom scripts — are rows in ctx0_triggers. The type column discriminates.
CREATE TABLE ctx0_triggers ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), user_id UUID NOT NULL REFERENCES users(id), -- Identity name TEXT NOT NULL, -- lowercase-hyphenated, unique per user description TEXT NOT NULL, -- Type discrimination type TEXT NOT NULL, -- 'composio_native' | 'cron_api' | 'cron_dom0' | 'cron_script' -- Composio native fields (only when type = 'composio_native') composio_trigger_id TEXT, -- External Composio trigger instance ID composio_slug TEXT, -- e.g., 'GMAIL_NEW_GMAIL_MESSAGE' composio_config JSONB, -- Composio trigger configuration -- Custom trigger fields (only when type starts with 'cron_') detection_script TEXT, -- The detection script content (synced across daemons) cron_expression TEXT, -- e.g., '*/5 * * * *' cron_timezone TEXT DEFAULT 'UTC', schedule_id UUID REFERENCES ctx0_schedules(id), -- Linked cron schedule -- State checkpoint (custom triggers only) -- Updated every poll. Contains last_seen_ids, hashes, timestamps, etc. -- Reset to '{}' to reprocess everything. state JSONB NOT NULL DEFAULT '{}', -- Shared fields (all trigger types) toolkit TEXT, -- Which service (gmail, instagram, etc.) response_prompt TEXT NOT NULL, -- What the agent does when trigger fires response_model TEXT, -- Model override (null = daemon default) permission_mode TEXT DEFAULT 'auto', -- auto | ask | dangerously_auto -- Targeting target_daemon_id UUID REFERENCES ctx0_daemons(id), target_device_id TEXT, -- Stats status TEXT DEFAULT 'active', -- active | paused | error_paused | disabled fire_count INTEGER DEFAULT 0, poll_count INTEGER DEFAULT 0, -- Custom triggers only last_fired_at TIMESTAMPTZ, last_polled_at TIMESTAMPTZ, -- Custom triggers only -- Error tracking (custom triggers only) last_error TEXT, -- Last detection error message (stderr or timeout) last_error_at TIMESTAMPTZ, -- When the last error occurred consecutive_failures INTEGER DEFAULT 0, -- Resets to 0 on successful poll error_log JSONB DEFAULT '[]', -- Capped array of last 20 errors (see Error Log below) -- Improvement notes (same pattern as skills) improvement_notes JSONB DEFAULT '[]', -- Provenance source_session_id UUID, source_daemon_id UUID, -- Timestamps created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), synced_at TIMESTAMPTZ, -- Last sync with Composio (native only) UNIQUE(user_id, name) ); CREATE INDEX idx_triggers_user ON ctx0_triggers(user_id); CREATE INDEX idx_triggers_user_status ON ctx0_triggers(user_id, status); CREATE INDEX idx_triggers_daemon ON ctx0_triggers(target_daemon_id);
Migration from ctx0_composio_triggers: Data migrates to ctx0_triggers with type = 'composio_native'. The old table is dropped after migration. All existing code (poller, executor, webhook handler) updates to read from the new table.
Data Model
┌─────────────────────────────────────────────────────────────────────────────┐
│ TRIGGER DATA MODEL │
│ │
│ ctx0_triggers (SINGLE SOURCE OF TRUTH) │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ id, user_id, name, type │ │
│ │ │ │
│ │ ┌── composio_native ──┐ ┌── cron_api / cron_dom0 / etc ──┐ │ │
│ │ │ composio_trigger_id │ │ detection_script │ │ │
│ │ │ composio_slug │ │ cron_expression │ │ │
│ │ │ composio_config │ │ schedule_id → ctx0_schedules │ │ │
│ │ └─────────────────────┘ │ state (JSONB checkpoint) │ │ │
│ │ └─────────────────────────────────┘ │ │
│ │ │ │
│ │ response_prompt, target_daemon_id, status, fire_count │ │
│ └────────────────────────────────────────────────────────────────┘ │
│ │ │
│ │ Events (both types produce the same events) │
│ ▼ │
│ ctx0_trigger_events (EXISTING — no changes) │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ id, trigger_id, payload, status, claimed_by, session_id │ │
│ └────────────────────────────────────────────────────────────────┘ │
│ │
│ ctx0_schedules (EXISTING — linked for custom triggers) │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ id, cron_expression, target_daemon_id, status │ │
│ │ settings: { is_trigger: true, trigger_id: '...' } │ │
│ └────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Event Flow
Native Composio Triggers
No changes from the current implementation. The only difference is the data source: ctx0_triggers instead of ctx0_composio_triggers.
External Service → Composio webhook → Bytespace webhook handler
→ INSERT ctx0_trigger_events (status: 'pending')
→ POST hub:/api/dispatch (wake up target daemon)
→ Daemon polls / receives wake-up
→ Claims event (lease-based, 120s)
→ Creates trigger session
→ Runs agent with event payload + response_prompt
→ Reports completion
Custom Triggers (Cron + Detection Script)
┌─────────────────────────────────────────────────────────────────────────────┐
│ CUSTOM TRIGGER EXECUTION FLOW │
│ │
│ Cron fires (via schedule system) │
│ │ │
│ ▼ │
│ Schedule executor detects settings.is_trigger = true │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────┐ │
│ │ 1. LOAD TRIGGER + STATE │ │
│ │ │ │
│ │ Fetch trigger row from ctx0_triggers│ │
│ │ Read: detection_script, state, │ │
│ │ response_prompt │ │
│ └──────────────┬──────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────┐ │
│ │ 2. RUN DETECTION SCRIPT │ │
│ │ │ │
│ │ Write state to /tmp/state.json │ │
│ │ Execute: npx tsx detect.ts │ │
│ │ --state /tmp/state.json │ │
│ │ │ │
│ │ Script reads state, fetches data, │ │
│ │ diffs, writes JSON result to stdout │ │
│ │ │ │
│ │ Timeout: 30s (configurable) │ │
│ └──────────────┬──────────────────────┘ │
│ │ │
│ ├── FAILED (non-zero exit, timeout, invalid JSON) │
│ │ │ │
│ │ ├── DO NOT update state (preserve checkpoint) │
│ │ ├── Log error, increment consecutive_failures │
│ │ ├── If consecutive_failures >= 5 → auto-pause │
│ │ └── Retry on next cron tick │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────┐ │
│ │ 3. PARSE RESULT (success path) │ │
│ │ │ │
│ │ stdout JSON: │ │
│ │ { │ │
│ │ "fired": true, │ │
│ │ "events": [{ type, payload }], │ │
│ │ "newState": { last_id: "130" } │ │
│ │ } │ │
│ └──────────────┬──────────────────────┘ │
│ │ │
│ ├── fired === false │
│ │ │ │
│ │ ├── UPDATE ctx0_triggers SET state = newState │
│ │ ├── INCREMENT poll_count │
│ │ └── Done (no event, no LLM, $0) │
│ │ │
│ └── fired === true │
│ │ │
│ ├── UPDATE ctx0_triggers SET state = newState │
│ ├── INCREMENT poll_count, fire_count │
│ ├── INSERT ctx0_trigger_events (status: 'pending') │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────┐ │
│ │ 4. EXECUTE RESPONSE │ │
│ │ │ │
│ │ Same path as native triggers: │ │
│ │ • Create trigger session │ │
│ │ • Inject response_prompt + payload │ │
│ │ • Run agent │ │
│ │ • Report completion │ │
│ └─────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Detection Script Contract
Detection scripts are regular executables. No framework, no special imports. The contract is just stdin/stdout JSON:
Input: State file path passed as --state argument. The file contains the previous state JSON (or {} on first run).
Output: JSON to stdout:
{ "fired": false, "newState": { "last_comment_id": "12347" } }
Or when triggered:
{ "fired": true, "events": [ { "type": "new_keyword_mentions", "payload": { "new_comments": [{ "id": "12348", "text": "Hello!", "user": "someone" }], "matched_keywords": ["hello"] } } ], "newState": { "last_comment_id": "12348" } }
Exit code: 0 = success (check fired field), non-zero = detection error.
API access for detection scripts: The daemon passes the proxy URL and session token via environment variables (PROXY_URL, SESSION_TOKEN). The script can make authenticated HTTP calls to Bytespace proxy endpoints (e.g., to execute Composio tools). This is the same trust boundary as any other daemon-executed script.
Example detection script (cron + Composio API):
#!/usr/bin/env npx tsx // detect.ts — Instagram keyword mention detector import { readFileSync } from 'fs'; const stateFile = process.argv.find((a, i) => process.argv[i - 1] === '--state') ?? ''; const state = JSON.parse(readFileSync(stateFile, 'utf-8')); const proxyUrl = process.env.PROXY_URL; const sessionToken = process.env.SESSION_TOKEN; // Config (passed via env or hardcoded during build) const keywords = (process.env.TRIGGER_KEYWORDS ?? '').split(',').map(k => k.trim().toLowerCase()); const accountId = process.env.TRIGGER_ACCOUNT_ID ?? ''; // Fetch current comments via Composio proxy const res = await fetch(`${proxyUrl}/api/proxy/composio/tools/execute/INSTAGRAM_GET_COMMENTS`, { method: 'POST', headers: { 'Authorization': `Bearer ${sessionToken}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ arguments: { account_id: accountId } }), }); const data = await res.json(); const comments = data.comments ?? []; // Diff against checkpoint const lastSeenId = state.last_comment_id ?? '0'; const newComments = comments.filter((c: any) => c.id > lastSeenId); const matches = newComments.filter((c: any) => keywords.some(kw => c.text.toLowerCase().includes(kw)) ); // Always update checkpoint const latestId = comments.reduce((max: string, c: any) => c.id > max ? c.id : max, lastSeenId); const newState = { last_comment_id: latestId }; if (matches.length === 0) { console.log(JSON.stringify({ fired: false, newState })); } else { console.log(JSON.stringify({ fired: true, events: [{ type: 'new_keyword_mentions', payload: { new_comments: matches, matched_keywords: [...new Set( matches.flatMap((m: any) => keywords.filter(kw => m.text.toLowerCase().includes(kw))) )] }, }], newState, })); }
The agent writes this script, runs it once to verify, then creates a schedule + trigger record to deploy it.
State Tracking Strategies
Different triggers need different state shapes. The agent chooses the right approach when building:
| Strategy | State shape | Diff logic | Use case |
|---|---|---|---|
| Cursor | { last_id: "123" } | id > last_id | Sequential data (comments, messages, feed items) |
| Hash | { content_hash: "sha256:..." } | hash !== stored_hash | Page content, DOM snapshots |
| Timestamp | { last_checked: "2026-02-28T..." } | created_after > timestamp | Time-ordered APIs |
| Custom | { ... } | Whatever the script needs | Complex multi-field state |
All strategies use the same ctx0_triggers.state JSONB column. The agent picks the right one based on the data source.
Two-Stage Filter Pattern
Some triggers need nuance that keyword matching can't provide. For example: "Notify me when someone says something negative about my brand on Instagram."
Keyword matching can't reliably detect sentiment. But running an LLM on every poll (288 times/day at 5-minute intervals) is expensive and violates the deterministic detection principle.
The solution is a two-stage filter: cheap programmatic detection gates expensive LLM judgment.
┌─────────────────────────────────────────────────────────────────────────────┐
│ TWO-STAGE FILTER │
│ │
│ Stage 1: DETECTION (programmatic, $0) │
│ ───────────────────────────────────── │
│ Detection script polls Instagram API for new comments. │
│ Programmatic filter: keyword match, @mention detection, regex. │
│ Catches broad candidates — some false positives are OK here. │
│ │
│ "Does this comment contain any brand-related keywords?" │
│ • "your product sucks" → YES (contains "product") │
│ • "nice weather today" → NO (skip) │
│ • "I hate bot0 support" → YES (contains "bot0") │
│ │
│ If any candidates found → trigger FIRES with candidates as payload. │
│ If none → exit, $0 cost. │
│ │
│ │ │
│ ▼ (only runs when detection fires — not on every poll) │
│ │
│ Stage 2: RESPONSE (LLM, ~$0.01-0.05) │
│ ───────────────────────────────────── │
│ Response prompt receives the candidate comments. │
│ LLM does the nuanced judgment: │
│ │
│ "Analyze these comments. Are any genuinely negative about our brand?" │
│ • "your product sucks" → NEGATIVE — draft response, alert user │
│ • "I hate bot0 support" → NEGATIVE — escalate to support team │
│ │
│ LLM can also decide to IGNORE false positives: │
│ • "I love your product, unlike competitors who suck" → POSITIVE (skip) │
│ │
│ Cost is per-fire, not per-poll. If the brand gets mentioned │
│ 5 times/day, that's 5 LLM calls, not 288. │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
How the agent sets this up:
- User says: "Notify me when someone says something negative about my brand"
- Agent asks: "Where? Twitter? Instagram? Both?"
- User: "Instagram"
- Agent checks for native triggers → none for comment sentiment
- Agent builds detection script:
- Polls
INSTAGRAM_GET_COMMENTSvia Composio - Filters for keywords related to the brand (user provides list)
- Any keyword match = fire (broad net, some false positives OK)
- Polls
- Agent writes response prompt:
New comments mentioning your brand were detected on Instagram. {{event_payload}} Analyze each comment for sentiment. For genuinely negative comments: 1. Summarize the issue 2. Draft a professional reply 3. Flag if it needs urgent attention Ignore false positives (positive/neutral mentions that happened to contain a keyword). - The LLM judgment happens in the response phase — after the trigger fired, not during detection.
Key insight: The detection script's job is to be a cheap, broad filter. It's OK if it lets some false positives through — the response LLM handles the nuance. What matters is that detection never misses a real event (high recall) and costs $0 per poll.
Enforcing No-LLM Detection
Detection scripts must be purely programmatic. Here's how this is enforced at each level:
Structural Enforcement (Primary)
Detection scripts run as plain script executions (npx tsx detect.ts), not agent sessions. The script process has:
- No AI SDK — no
streamText(), no tool loop, no agent reasoning - No tool registry — no
bash,write_file, or any daemon tool - No Anthropic/OpenAI client — no SDK imported, no client instantiated
To use an LLM, the agent building the trigger would have to manually write raw HTTP fetch() calls to the LLM proxy endpoint inside the detection script. This is technically possible but requires deliberate effort to circumvent the pattern.
Environment Enforcement (Phase 1)
The trigger runner passes a restricted set of environment variables to the detection script:
// What detection scripts receive: { PROXY_URL: '...', // For Composio/service API calls SESSION_TOKEN: '...', // Auth for proxy TRIGGER_*: '...', // Trigger-specific config (inputs) } // What detection scripts do NOT receive: // - No LLM_PROXY_URL (separate from PROXY_URL in future) // - No ANTHROPIC_API_KEY // - No OPENAI_API_KEY
Scoped Token Enforcement (Future — Phase 3)
Real infrastructure-level enforcement: the trigger runner requests a detection-scoped proxy token from Bytespace that only allows non-LLM endpoints:
Detection-scoped token allows:
✓ POST /api/proxy/composio/* (Composio tool execution)
✓ GET /api/proxy/composio/* (Composio data fetching)
✗ POST /api/proxy/llm/* (BLOCKED — 403)
✗ POST /api/proxy/db/* (BLOCKED — 403)
If the detection script tries to call the LLM proxy, it gets a 403. This is the definitive enforcement mechanism, independent of what code the agent writes.
Convention Enforcement (Always)
The agent's system prompt, the trigger deploy tool description, and the trigger tool documentation all state:
Detection scripts must be deterministic. No LLM calls. No agent reasoning. Only data fetching and programmatic comparison. The LLM runs in the response phase after the trigger fires.
Combined with structural enforcement (no AI SDK available), this is sufficient for Phase 1. Scoped tokens add belt-and-suspenders for Phase 3.
Detection Failure Handling
Detection scripts can fail — API rate limits, network errors, auth expiration, script bugs, timeout. The trigger runner must handle these gracefully without losing events or corrupting state.
Failure Behavior
┌─────────────────────────────────────────────────────────────────────────────┐
│ DETECTION FAILURE HANDLING │
│ │
│ Detection script fails (non-zero exit code or timeout) │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────┐ │
│ │ 1. DO NOT UPDATE STATE │ │
│ │ │ │
│ │ CRITICAL: If detection failed │ │
│ │ mid-way, we don't know the true │ │
│ │ current state. Preserving the old │ │
│ │ checkpoint means the next successful│ │
│ │ poll picks up where we left off. │ │
│ │ No missed events. │ │
│ └──────────────┬──────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────┐ │
│ │ 2. DO NOT FIRE │ │
│ │ │ │
│ │ No event created. No response │ │
│ │ agent started. Failed detection │ │
│ │ is a no-op (except logging). │ │
│ └──────────────┬──────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────┐ │
│ │ 3. LOG THE ERROR │ │
│ │ │ │
│ │ Record in trigger row: │ │
│ │ • last_error (stderr or timeout msg)│ │
│ │ • last_error_at (timestamp) │ │
│ │ • consecutive_failures (increment) │ │
│ │ • Still increment poll_count │ │
│ └──────────────┬──────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────┐ │
│ │ 4. RETRY ON NEXT CRON TICK │ │
│ │ │ │
│ │ The schedule keeps firing normally. │ │
│ │ Next poll runs the detection script │ │
│ │ again with the preserved state. │ │
│ │ Transient errors self-heal. │ │
│ └──────────────┬──────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────┐ │
│ │ 5. AFTER N CONSECUTIVE FAILURES │ │
│ │ (default: 5) │ │
│ │ │ │
│ │ • Create improvement note with: │ │
│ │ - Last N error messages │ │
│ │ - Failure timestamps │ │
│ │ - Script that failed │ │
│ │ │ │
│ │ • Auto-pause the trigger │ │
│ │ (set status = 'error_paused') │ │
│ │ │ │
│ │ • Notify user via desktop │ │
│ │ (if connected) │ │
│ │ │ │
│ │ Trigger stays paused until user │ │
│ │ or agent fixes and resumes it. │ │
│ └─────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Error Classification
| Error type | Example | Behavior |
|---|---|---|
| Transient | Network timeout, API rate limit, 503 | Retry next tick. Usually self-heals. |
| Auth expired | 401 from Composio, expired token | Retry next tick. If persists (3+ failures), likely needs re-auth. Improvement note: "Re-authenticate Instagram connection." |
| Script bug | TypeError, undefined variable | Won't self-heal. Consecutive failure threshold triggers pause + improvement note. |
| Timeout | Script exceeds 30s limit | Could be transient (slow API) or permanent (infinite loop). Retry, pause after threshold. |
| Invalid output | stdout isn't valid JSON, missing fired field | Script bug. Treated as failure — state preserved, improvement note after threshold. |
Error Log
Each detection failure is appended to the error_log JSONB array on the trigger row. The array is capped at 20 entries — oldest entries are dropped when the cap is exceeded.
[ { "timestamp": "2026-02-28T10:00:00Z", "error": "401 Unauthorized — Instagram token expired", "stderr": "", "exit_code": 1, "duration_ms": 850 }, { "timestamp": "2026-02-28T10:05:00Z", "error": "401 Unauthorized — Instagram token expired", "stderr": "", "exit_code": 1, "duration_ms": 790 }, { "timestamp": "2026-02-28T10:10:00Z", "error": "TypeError: Cannot read property 'comments' of undefined", "stderr": "at detect.ts:42:15\n at processComments (detect.ts:38:5)", "exit_code": 1, "duration_ms": 1200 } ]
This gives the user and agent enough context to debug:
- Same error every time → likely a permanent issue (auth expired, script bug)
- Different errors → flaky dependency (intermittent API, rate limits)
- First error differs from rest → cascading failure (one thing broke, then everything after it fails differently)
Error Tracking Behavior
| Event | consecutive_failures | last_error | error_log |
|---|---|---|---|
| Successful poll | Reset to 0 | Set to NULL | Preserved (not cleared — visible for post-recovery debugging) |
| Failed poll | +1 | Set to error message | Append entry (cap at 20) |
| 5 consecutive failures | 5 | Last error | Contains last 5 errors for improvement note |
| User resumes trigger | Reset to 0 | Set to NULL | Cleared (fresh start) |
| User resets state | Unchanged | Unchanged | Unchanged |
When consecutive_failures >= 5:
- Auto-pause trigger (
status = 'error_paused') - Create improvement note containing the full
error_logarray - Notify user via desktop (if connected)
The error log persists after recovery (successful polls) so the user can review what happened. It only clears when the user explicitly resumes a paused trigger — signaling they've addressed the issue.
The Golden Rule
Never update state on failure. This is the single most important rule. If the detection script crashed after fetching some data but before computing the diff, the state could be in an inconsistent position. By preserving the old checkpoint, the next successful run re-fetches everything from the last known-good point. The worst case is duplicate processing (which the response agent can handle), never missed events.
How the Agent Builds a Custom Trigger
No special builder mode. The agent uses its normal tools. Here's the typical flow:
┌─────────────────────────────────────────────────────────────────────────────┐
│ AGENT BUILDS A CUSTOM TRIGGER │
│ │
│ User: "Alert me when someone mentions 'hello' on my Instagram" │
│ │ │
│ ▼ │
│ Step 1: CHECK NATIVE TRIGGERS │
│ │ │
│ │ bytespace_trigger { action: "list_types", toolkit: "instagram" } │
│ │ → No native trigger for comment mentions. │
│ │ │
│ ▼ │
│ Step 2: CHECK API TOOLS │
│ │ │
│ │ bytespace_search { query: "instagram comments" } │
│ │ → Found INSTAGRAM_GET_COMMENTS. Can poll via API. │
│ │ │
│ ▼ │
│ Step 3: TELL USER THE PLAN │
│ │ │
│ │ "Instagram doesn't have a native trigger for this. │
│ │ I'll build a detection script that polls every 5 minutes │
│ │ using the Instagram API, checks for new comments │
│ │ matching 'hello', and alerts you when found. │
│ │ Shall I proceed?" │
│ │ │
│ │ User: "Yes, go ahead" │
│ │ │
│ ▼ │
│ Step 4: WRITE DETECTION SCRIPT │
│ │ │
│ │ write_file: .bot0/triggers/instagram-mentions/detect.ts │
│ │ (Regular file write — no special tool needed) │
│ │ │
│ ▼ │
│ Step 5: TEST THE SCRIPT │
│ │ │
│ │ bash: echo '{}' > /tmp/state.json && │
│ │ PROXY_URL=... SESSION_TOKEN=... npx tsx detect.ts │
│ │ --state /tmp/state.json │
│ │ │
│ │ Output: { "fired": true, "events": [...], "newState": {...} } │
│ │ │
│ │ "Detection script works! Found 3 comments with 'hello'. │
│ │ Running again with updated state to verify no false fires..." │
│ │ │
│ │ (Runs again with the newState from previous run) │
│ │ Output: { "fired": false, "newState": {...} } │
│ │ │
│ │ "Confirmed: no duplicate fires. Ready to deploy." │
│ │ │
│ ▼ │
│ Step 6: DEPLOY │
│ │ │
│ │ trigger { action: "deploy", ... } │
│ │ → Creates ctx0_triggers row (type: 'cron_api') │
│ │ → Creates linked ctx0_schedules row (*/5 * * * *) │
│ │ → Shows deployment confirmation (reuses schedule confirm gate) │
│ │ │
│ │ User confirms → trigger is live. │
│ │ │
│ ▼ │
│ Done. Trigger polls every 5 minutes on target daemon. │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Key simplification: The agent doesn't need a special mode to build triggers. It uses write_file to write scripts, bash to test them, and the trigger tool to deploy. The "testing framework" is just... running the script twice and checking the output.
Trigger Management Tool
A single trigger tool handles all trigger operations (both native and custom):
{ name: "trigger", description: "Manage event triggers. Use this to create native triggers, deploy custom triggers, and control existing triggers.", inputSchema: { type: "object", properties: { action: { type: "string", enum: [ // Discovery "list", // List all triggers (native + custom) "get", // Get trigger details + recent fires "list_native_types", // List available Composio trigger types per toolkit // Creation "create_native", // Register a Composio native trigger "deploy", // Deploy a custom trigger (creates schedule + trigger row) // Control "pause", // Pause (stops cron / disables webhook) "resume", // Resume paused trigger "delete", // Delete trigger + schedule + events // Testing (custom triggers) "dry_run", // Run detection script, show what would fire "reset_state", // Clear state checkpoint (reprocess everything) // Daemon routing "network", // List available 24/7 daemons ] }, // For create_native trigger_slug: { type: "string", description: "Composio trigger slug" }, trigger_config: { type: "object", description: "Composio trigger config" }, // For deploy name: { type: "string", description: "Trigger name (lowercase-hyphenated)" }, description: { type: "string" }, detection_script_path: { type: "string", description: "Path to detection script" }, cron_expression: { type: "string", description: "Cron expression for polling" }, response_prompt: { type: "string", description: "What the agent does when trigger fires" }, initial_state: { type: "object", description: "Initial state checkpoint. If omitted, deploy runs a seeding poll: detection script executes once with {} state, captures newState, but does NOT fire. This prevents the first real poll from treating all historical data as new." }, // Shared trigger: { type: "string", description: "Trigger name or ID (for get/pause/resume/delete/dry_run)" }, target_daemon: { type: "string", description: "Daemon ID/name/alias" }, toolkit: { type: "string", description: "Service name (gmail, instagram, etc.)" }, }, required: ["action"] } }
This replaces the current bytespace_trigger tool. One tool for all trigger operations.
Deployment & Daemon Assignment
The Schedule-Trigger Link
Custom triggers are powered by cron schedules. When a trigger is deployed:
ctx0_triggersrow created — with type, detection_script, cron_expression, state, response_promptctx0_schedulesrow created — linked viaschedule_idFK. The schedule'ssettingscontains{ is_trigger: true, trigger_id: "..." }- State seeded — If
initial_stateis provided, use it directly. Otherwise, the deploy action runs a seeding poll: execute the detection script once with{}state, capturenewStatefrom the output, but do NOT fire even iffired: true. This sets the checkpoint to "now" so the first real cron tick only detects genuinely new changes, not all historical data.
The schedule executor checks settings.is_trigger. If true, it runs the trigger runner instead of a regular agent prompt. This avoids the @trigger: prefix hack — it's a clean flag in the settings JSONB.
Daemon Routing
- Custom triggers require an agent-role daemon (24/7). Daily-drivers are rejected.
- Native triggers target a specific daemon via
target_daemon_id. If the daemon is offline, events queue inctx0_trigger_eventsand are processed when the daemon comes back online. No events are lost. - The
trigger networkaction lists available 24/7 daemons (same asschedule network).
What Happens When the Target Daemon Is Offline
Native trigger fires → Event queued in ctx0_trigger_events → Daemon comes online → Poller catches up
Custom trigger cron fires → Schedule executor on target daemon... but daemon is offline?
→ The cron won't fire because QStash/scheduler can't reach the daemon
→ Events are missed during downtime
→ When daemon comes back, next cron tick catches new changes (state-based)
→ Some events may be lost depending on the data source's retention
Future: auto-failover to another online agent daemon
Comparison: Triggers vs Schedules
┌─────────────────────────────────────────────────────────────────────────────┐
│ TRIGGERS vs SCHEDULES │
│ │
│ Trigger Schedule │
│ ────────── ───────── ──────── │
│ Runs when Event detected Cron fires │
│ Detection Script or webhook N/A (time-based) │
│ Has state Yes (JSONB on row) No │
│ Response Prompt + agent Prompt + agent │
│ Daemon target Required (24/7) Required (24/7) │
│ Database ctx0_triggers ctx0_schedules │
│ Testing Dry run before deploy Manual run │
│ │
│ Key distinction: │
│ • Schedule = "do X every Y time" │
│ • Trigger = "do X when Y changes" │
│ │
│ A custom trigger = schedule + detection script + state checkpoint. │
│ The schedule provides the timing. The trigger adds change detection. │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Trigger Improvement Notes
Same pattern as skills. When a trigger's detection script fails or produces false positives, the executing agent documents the issue:
interface TriggerImprovementNote { id: string; timestamp: string; sessionId: string; daemonId: string; phase: 'detection' | 'response'; // Which part failed error: string; diagnosis: string; suggestedFix: string; status: 'open' | 'dismissed' | 'resolved'; }
Stored in ctx0_triggers.improvement_notes (JSONB array). The user sees notification badges on triggers with open notes and can ask the agent to fix them (agent reads the note, modifies the detection script, re-tests, re-deploys).
Security Considerations
Detection Script Execution
Detection scripts run on the daemon with the same trust level as any agent-executed code. The no-LLM constraint is enforced structurally, environmentally, and by convention — see Enforcing No-LLM Detection for the full enforcement stack. Detection failure handling preserves state safety — see Detection Failure Handling.
Response Agent Permissions
| Mode | Use case | Behavior |
|---|---|---|
dangerously_auto | High-trust, well-tested triggers | Auto-approves all tool calls |
auto | Default | Blocks on dangerous commands |
ask | Sensitive actions | Every tool call needs user approval (requires connected desktop) |
State Security
- State is stored per-user in Supabase with RLS
- State is scoped to individual trigger instances
- State is JSON-serializable (no executable content)
- State reset is a simple
SET state = '{}'
Implementation Roadmap
Phase 1 — Unified Table + Native Trigger Migration
| Task | Complexity |
|---|---|
Create ctx0_triggers Drizzle schema | Medium |
Migrate ctx0_composio_triggers data → ctx0_triggers | Low |
Update trigger poller to read from ctx0_triggers | Low |
Update trigger executor to read response_prompt from ctx0_triggers | Low |
Update webhook handler to write to ctx0_triggers | Low |
Drop ctx0_composio_triggers | Low |
Update bytespace_trigger tool → unified trigger tool | Medium |
Phase 2 — Custom Trigger Infrastructure
| Task | Complexity |
|---|---|
Trigger runner (schedule executor recognizes is_trigger flag) | Medium |
| Detection script execution (write state file, run script, parse stdout JSON) | Medium |
State read/write on ctx0_triggers.state column | Low |
trigger deploy action (create trigger row + linked schedule) | Medium |
trigger dry_run action (run detection without firing response) | Low |
Deploy confirmation gate (reuse ScheduleConfirmationManager) | Low |
| Bytespace proxy CRUD endpoints for triggers | Medium |
Phase 3 — UI & Monitoring
| Task | Complexity |
|---|---|
| Triggers section in NetworkSidebar (unified list) | Medium |
Trigger fire history (query ctx0_trigger_events per trigger) | Low |
| Improvement notes display + badge | Medium |
trigger reset_state action | Low |
| Trigger pause/resume/delete | Low |
Phase 4 — Advanced Detection Methods
| Task | Complexity |
|---|---|
| dom0-based detection scripts (browser automation) | High |
| cmd0-based detection scripts (visual grounding) | High |
| Auto-failover to backup daemon on downtime | High |
Related Documentation
- Cron Scheduler System Architecture — Schedule infrastructure (triggers are powered by schedules)
- bot0 Skills System Architecture — Skill building lifecycle and improvement notes pattern
- bot0 Proactive Architecture — Proactive engine design
- bot0 System Architecture — Core system design
- bot0 Remote Daemon — Hub relay and remote tasks
- Bytespace Proxy — Credential proxy architecture