Daemon is AgentCore's resident process. It monitors event sources and automatically runs workflows when conditions are met. It can react to cron schedules, file changes, webhooks, external commands, and more.
- Use cases
- Start with a minimal config
- YAML schema
- Event sources
- Triggers
- Intelligent layer
- Context injection
- State management
- CLI usage
- Walkthrough examples
- Periodic monitoring: run a system health check every 30 seconds
- Auto tests: detect source changes and run tests automatically
- Intelligent review: ask an LLM for a code review only when there are new commits
- Webhook integration: receive GitHub events and start a CI workflow
- External API monitoring: detect API status changes and run an alert workflow
The simplest daemon uses a single interval event and one workflow.
# my-daemon.yaml
name: my-first-daemon
version: "1"
workspace: "/tmp/my-daemon"
state_dir: "/tmp/my-daemon/.daemon-state"
events:
tick:
type: interval
every: "30s"
triggers:
health:
on: tick
workflow: ./workflows/health-check.yaml
on_workflow_failure: ignoreStart:
roboppi daemon my-daemon.yaml --verbose
# (dev) bun run src/daemon/cli.ts my-daemon.yaml --verboseOutput:
Daemon: my-first-daemon
Events: 1
Triggers: 1
[daemon] Event loop started, waiting for events...
[daemon] Event received: tick (interval)
[daemon] Workflow completed: SUCCEEDED
Stop safely with Ctrl+C (graceful shutdown).
Daemon configuration is defined in a single YAML file.
name: string # daemon name (identifier)
version: "1" # schema version (currently fixed to "1")
description?: string # optional description
workspace: string # working directory (shared by all triggers)
agents_file?: string # optional agent catalog YAML for step.agent resolution
log_dir?: string # log output directory (default: ./logs)
state_dir?: string # state directory (default: <workspace>/.daemon-state)
max_concurrent_workflows?: number # max concurrent workflows (default: 5)
events: # event source definitions
<event_id>:
type: cron | interval | fswatch | webhook | command
...
triggers: # trigger definitions (event -> workflow)
<trigger_id>:
on: <event_id>
workflow: <path>
...| Field | Required | Description |
|---|---|---|
name |
yes | daemon identifier |
version |
yes | fixed to "1" |
description |
no | description |
workspace |
yes | working directory for workflow execution |
agents_file |
no | agent catalog YAML used to resolve workflow step.agent |
log_dir |
no | log output directory |
state_dir |
no | where execution state is persisted |
max_concurrent_workflows |
no | max workflows running concurrently (default: 5) |
events |
yes | event sources (at least one) |
triggers |
yes | triggers (at least one) |
If your workflows use step.agent, provide an agent catalog to the daemon:
agents_file: a default catalog path for the daemon (relative paths are resolved fromworkspace)ROBOPPI_AGENTS_FILE: you can also set this via environment variables (or per-trigger viatriggers.<id>.context.env) as a colon-separated list of YAML paths
When multiple catalogs are provided, later sources override earlier ones (so a per-trigger context.env.ROBOPPI_AGENTS_FILE can override the daemon-wide catalog).
See also: docs/guides/agents.md.
Daemon supports five event source kinds. Define them under events and reference them from triggers via on.
The simplest event source: fires periodically at a fixed interval.
events:
tick:
type: interval
every: "30s" # every 30 secondsevery is a DurationString. Supported forms:
| Form | Example | Meaning |
|---|---|---|
Nms |
"200ms" |
200 milliseconds |
Ns |
"30s" |
30 seconds |
Nm |
"5m" |
5 minutes |
Nh |
"1h" |
1 hour |
Payload:
{
"type": "interval",
"firedAt": 1705312200000
}Specify a schedule with a standard cron expression.
events:
every-5min:
type: cron
schedule: "*/5 * * * *" # every 5 minutes
daily-morning:
type: cron
schedule: "0 9 * * *" # every day at 09:00
weekday-night:
type: cron
schedule: "0 22 * * 1-5" # weekdays at 22:00Cron format:
* * * * *
| | | | |
| | | | +-- day of week (0-7; 0 and 7 are Sunday)
| | | +---- month (1-12)
| | +------ day of month (1-31)
| +-------- hour (0-23)
+---------- minute (0-59)
Payload:
{
"type": "cron",
"schedule": "*/5 * * * *",
"firedAt": 1705312200000
}Watch filesystem changes. Specify files via glob patterns and fire when changes occur.
events:
src-change:
type: fswatch
paths: # watched paths (glob patterns)
- "src/**/*.ts"
- "src/**/*.tsx"
ignore: # ignored patterns (optional)
- "**/*.test.ts"
- "**/*.spec.ts"
- "**/node_modules/**"
- "**/dist/**"
events: [create, modify] # watched event kinds (optional)| Field | Required | Description |
|---|---|---|
paths |
yes | array of glob patterns to watch |
ignore |
no | ignore patterns |
events |
no | subset of create, modify, delete (default: all) |
If a large number of changes happen in a short time, they are batched into one event using a 200ms window.
Payload:
{
"type": "fswatch",
"changes": [
{ "path": "src/index.ts", "event": "modify" },
{ "path": "src/utils.ts", "event": "create" }
]
}Receive external events via an HTTP endpoint. All webhook sources share a single HTTP server within the daemon.
events:
github-push:
type: webhook
path: "/hooks/github" # endpoint path
port: 8080 # listen port (optional; default: 8080)
secret: "${GITHUB_WEBHOOK_SECRET}" # optional HMAC-SHA256 secret
method: "POST" # optional; default: POST| Field | Required | Description |
|---|---|---|
path |
yes | URL path (e.g. /hooks/github) |
port |
no | listen port (default: 8080) |
secret |
no | HMAC-SHA256 secret; env expansion supported via ${ENV_VAR} |
method |
no | allowed HTTP method (default: POST) |
If secret is set, requests are validated using the X-Hub-Signature-256 header. Invalid requests are rejected.
Payload:
{
"type": "webhook",
"method": "POST",
"path": "/hooks/github",
"headers": { "content-type": "application/json", "x-github-event": "push" },
"body": { "ref": "refs/heads/main", "commits": [] }
}Periodically run an external command and emit events based on its result (or changes).
events:
api-status:
type: command
command: "curl -s -o /dev/null -w '%{http_code}' https://api.example.com/health"
interval: "1m" # execution interval
trigger_on: change # change | always| Field | Required | Description |
|---|---|---|
command |
yes | shell command to run |
interval |
yes | interval (DurationString) |
trigger_on |
no | change (default) fires only when stdout changes; always fires every run |
With trigger_on: change, the first execution does not fire (no previous output to compare). From the second run, events fire when stdout differs.
Payload:
{
"type": "command",
"stdout": "200",
"exitCode": 0,
"changed": true
}Triggers connect events to workflows. You can configure filtering, rate control, and failure handling.
triggers:
auto-test:
on: src-change # referenced event id
workflow: ./workflows/test.yaml # workflow YAML path
enabled: true # enable/disable (default: true)| Field | Required | Description |
|---|---|---|
on |
yes | event id (key under events) |
workflow |
yes | workflow YAML path (relative to workspace) |
enabled |
no | set false to disable the trigger (default: true) |
Use filter to specify conditions on the event payload. The workflow runs only when all conditions match.
triggers:
pr-check:
on: github-push
workflow: ./workflows/ci.yaml
filter:
# exact match
action: "opened"
# dot notation for nested fields
pull_request.base.ref: "main"
# regex match
ref:
pattern: "^refs/heads/(main|develop)$"
# list membership
sender.login:
in: ["user-a", "user-b", "bot-ci"]Filter kinds:
| Kind | Example | Meaning |
|---|---|---|
| exact match | action: "opened" |
value equals |
| regex | ref: { pattern: "^refs/heads/main$" } |
matches regex |
| list | login: { in: ["a", "b"] } |
is one of the list values |
Use dot notation (pull_request.base.ref, etc.) to access nested fields.
Control how frequently triggers execute.
triggers:
auto-test:
on: src-change
workflow: ./workflows/test.yaml
debounce: "5s" # ignore rapid bursts (wait 5s from last event)
cooldown: "30s" # do not rerun for 30s after completion
max_queue: 5 # max pending queue size (default: 10)| Field | Description |
|---|---|
debounce |
ignore new events until a duration has elapsed since the last event |
cooldown |
after a workflow completes, do not rerun until duration elapses |
max_queue |
pending queue upper bound; discard overflow from oldest |
debounce is useful to aggregate save bursts from file watchers. cooldown prevents immediate reruns after completion.
Specify what to do when the workflow fails.
triggers:
ci-check:
on: github-push
workflow: ./workflows/ci.yaml
on_workflow_failure: retry # ignore | retry | pause_trigger
max_retries: 2 # max retries (default: 3)| Value | Meaning |
|---|---|
ignore |
ignore failures and wait for the next event |
retry |
rerun up to max_retries |
pause_trigger |
pause the trigger when consecutive failures reach max_retries |
Daemon can incorporate intelligent decisions via LLM workers or shell scripts.
An optional gate that decides "should we run this workflow?" before execution.
triggers:
code-review:
on: periodic
workflow: ./workflows/review.yaml
evaluate:
worker: CUSTOM # CUSTOM | CLAUDE_CODE | CODEX_CLI | OPENCODE
instructions: |
cd {{workspace}} 2>/dev/null || exit 1
CURRENT=$(git rev-parse HEAD 2>/dev/null || echo "none")
LAST=$(cat ".daemon-state/.last-review-commit" 2>/dev/null || echo "")
if [ "$CURRENT" = "$LAST" ]; then
exit 1 # skip
else
mkdir -p .daemon-state
echo "$CURRENT" > ".daemon-state/.last-review-commit"
exit 0 # run
fi
capabilities: [READ, RUN_COMMANDS]
timeout: "15s"| worker | Decision rule |
|---|---|
CUSTOM |
run as a shell script: exit 0 = run, exit 1 = skip |
CLAUDE_CODE |
run Claude Code CLI: if output contains "run" => run; if it contains "skip" => skip |
CODEX_CLI |
same as CLAUDE_CODE |
OPENCODE |
same as CLAUDE_CODE |
For LLM workers, the daemon checks the last non-empty output line first. If neither "run" nor "skip" is present, it defaults to skip (safer).
| Field | Required | Description |
|---|---|---|
worker |
yes | worker kind |
instructions |
yes | instructions (template vars supported) |
capabilities |
yes | required permissions |
timeout |
no | timeout (default: "30s") |
Analyze workflow results after completion and generate reports/summaries.
triggers:
code-review:
on: periodic
workflow: ./workflows/review.yaml
analyze:
worker: CUSTOM
instructions: |
echo "=== Review Summary ===" > summary.md
echo "Status: {{workflow_status}}" >> summary.md
echo "Time: $(date)" >> summary.md
cat summary.md
capabilities: [READ, EDIT]
timeout: "30s"
outputs:
- name: review-summary
path: summary.md| Field | Required | Description |
|---|---|---|
worker |
yes | worker kind |
instructions |
yes | analysis instructions (template vars supported) |
capabilities |
yes | required permissions |
timeout |
no | timeout (default: "2m") |
outputs |
no | file outputs for analysis results |
analyze runs only when the workflow completes with SUCCEEDED. The worker can access the workflow context/ directory and read per-step results.
You can use {{var}} placeholders in evaluate and analyze instructions.
| Variable | Description | Where |
|---|---|---|
{{event}} |
event payload (JSON string) | evaluate, analyze |
{{event.type}} |
event type | evaluate, analyze |
{{last_result}} |
previous workflow result (JSON string) | evaluate |
{{last_result.status}} |
previous status | evaluate |
{{timestamp}} |
current time (ISO 8601) | evaluate, analyze |
{{trigger_id}} |
trigger id | evaluate, analyze |
{{workspace}} |
workspace path | evaluate |
{{execution_count}} |
total execution count for this trigger | evaluate, analyze |
{{workflow_status}} |
workflow status | analyze |
{{steps}} |
step results (JSON) | analyze |
{{context_dir}} |
context directory path | analyze |
Dot notation works for nested fields:
instructions: |
Event type: {{event.type}}
Previous status: {{last_result.status}}Template resolution order:
- exact key match (e.g.
vars["event.type"]) - dot-path lookup by parsing JSON in
vars["event"]and accessing.type - unresolved placeholders are left as-is (
{{unknown_var}})
Use the trigger's context section to pass extra information to workflows.
triggers:
review:
on: periodic
workflow: ./workflows/review.yaml
context:
env:
REVIEW_MODE: "strict"
TARGET_BRANCH: "main"
last_result: true
event_payload: trueWhen the workflow runs, these environment variables are set on process.env, then restored after completion.
If true, the previous workflow result is written to .daemon-context/last-result.json. Workflow steps can read this file.
{
"workflowId": "review-1705312200000",
"name": "code-review",
"status": "SUCCEEDED",
"steps": {},
"startedAt": 1705312200000,
"completedAt": 1705312260000
}If true, the payload of the triggering event is written to .daemon-context/event.json.
{
"type": "cron",
"schedule": "*/5 * * * *",
"firedAt": 1705312200000
}Daemon persists state as files under state_dir (default: <workspace>/.daemon-state).
.daemon-state/
├── daemon.json # daemon metadata (PID, start time, status)
└── triggers/
├── auto-test/
│ ├── state.json # enabled, lastFiredAt, cooldownUntil, executionCount
│ ├── last-result.json # last workflow result
│ └── history/
│ ├── 1705312200000.json
│ └── 1705312500000.json
└── periodic-review/
├── state.json
├── last-result.json
└── history/
└── ...
{
"pid": 12345,
"startedAt": 1705312200000,
"configName": "my-daemon",
"status": "running"
}{
"enabled": true,
"lastFiredAt": 1705312200000,
"cooldownUntil": null,
"executionCount": 42,
"consecutiveFailures": 0
}consecutiveFailures is used with on_workflow_failure: pause_trigger. When consecutive failures reach max_retries, enabled is flipped to false.
History is stored as <completedAt>.json files. Each includes event info, workflow result, and timestamps.
roboppi daemon <daemon.yaml> [options]
# (dev) bun run src/daemon/cli.ts <daemon.yaml> [options]| Option | Description |
|---|---|
--workspace, -w |
override workspace via CLI |
--verbose, -v |
enable verbose logging |
--supervised |
supervised mode (default): run workflows via Core IPC (Supervisor -> Core -> Worker) |
--direct |
direct mode: spawn worker processes directly (no Core IPC) |
--no-supervised |
alias for --direct |
--help, -h |
show help |
Notes:
- command event
cwdand relativefswatch.pathsresolution are based onworkspace workspace/state_dir/log_dir/triggers.*.workflowsupport${ENV_VAR}expansion (startup error if missing)
Examples:
roboppi daemon my-daemon.yaml
roboppi daemon my-daemon.yaml --verbose
# (dev) bun run src/daemon/cli.ts my-daemon.yaml
# (dev) bun run src/daemon/cli.ts my-daemon.yaml --verboseOn Ctrl+C (SIGINT) or SIGTERM, Daemon performs a graceful shutdown:
- stop receiving new events
- abort running workflows (best-effort)
- stop all event sources
- wait for running workflows to finish (30s timeout)
- stop webhook server
- (supervised mode) shut down Core
- flush state and exit
Sample configs live under examples/daemon/.
A minimal setup that runs a health-check workflow every 30 seconds.
name: simple-cron
version: "1"
description: "A simple daemon that runs health checks every 30 seconds"
workspace: "/tmp/roboppi-daemon-simple"
state_dir: "/tmp/roboppi-daemon-simple/.daemon-state"
events:
tick:
type: interval
every: "30s"
triggers:
health-check:
on: tick
workflow: ./workflows/health-check.yaml
on_workflow_failure: ignoreRun:
roboppi daemon examples/daemon/simple-cron.yaml --verbose
# (dev) bun run src/daemon/cli.ts examples/daemon/simple-cron.yaml --verboseThe health-check workflow (workflows/health-check.yaml) is a 1-step workflow that checks disk usage, memory, and load average.
Fires every 5 minutes via cron. An evaluate gate checks whether there are new commits. Only if there are new commits does it run a review workflow, then generates a summary via analyze.
name: smart-reviewer
version: "1"
workspace: "/tmp/roboppi-daemon-reviewer"
state_dir: "/tmp/roboppi-daemon-reviewer/.daemon-state"
max_concurrent_workflows: 1
events:
periodic:
type: cron
schedule: "*/5 * * * *"
triggers:
code-review:
on: periodic
workflow: ./workflows/code-review.yaml
cooldown: "5m"
evaluate:
worker: CUSTOM
instructions: |
cd {{workspace}} 2>/dev/null || exit 1
git rev-parse --git-dir > /dev/null 2>&1 || exit 1
MARKER=".daemon-state/.last-review-commit"
CURRENT=$(git rev-parse HEAD 2>/dev/null || echo "none")
LAST=$(cat "$MARKER" 2>/dev/null || echo "")
if [ "$CURRENT" = "$LAST" ]; then
exit 1 # no new commits -> skip
else
mkdir -p .daemon-state
echo "$CURRENT" > "$MARKER"
exit 0 # new commits -> run
fi
capabilities: [READ, RUN_COMMANDS]
timeout: "15s"
context:
last_result: true
analyze:
worker: CUSTOM
instructions: |
echo "=== Review Summary ===" > summary.md
echo "Status: {{workflow_status}}" >> summary.md
echo "Time: $(date)" >> summary.md
cat summary.md
capabilities: [READ, EDIT]
timeout: "30s"
outputs:
- name: review-summary
path: summary.mdKey points:
evaluate.worker: CUSTOMuses a shell gate{{workspace}}template variable references the workspacecontext.last_result: trueinjects the previous resultanalyzeauto-generates a summary of review results
Detect TypeScript file changes and run tests automatically.
name: file-watcher
version: "1"
workspace: "/tmp/roboppi-daemon-watcher"
state_dir: "/tmp/roboppi-daemon-watcher/.daemon-state"
events:
src-change:
type: fswatch
paths:
- "src/**/*.ts"
- "src/**/*.tsx"
ignore:
- "**/*.test.ts"
- "**/*.spec.ts"
- "**/node_modules/**"
- "**/dist/**"
events: [create, modify]
triggers:
auto-test:
on: src-change
workflow: ./workflows/test-suite.yaml
debounce: "5s"
cooldown: "30s"
on_workflow_failure: ignoreKey points:
debounce: "5s"aggregates bursts of file-save eventscooldown: "30s"prevents immediate reruns after completion- test failures are ignored and the daemon waits for the next change
Use interval, cron, and command event sources together and start different workflows conditionally.
name: multi-trigger
version: "1"
workspace: "/tmp/roboppi-daemon-multi"
state_dir: "/tmp/roboppi-daemon-multi/.daemon-state"
max_concurrent_workflows: 2
events:
heartbeat:
type: interval
every: "30s"
hourly:
type: cron
schedule: "0 * * * *"
api-status:
type: command
command: "curl -s -o /dev/null -w '%{http_code}' https://httpbin.org/status/200"
interval: "1m"
trigger_on: change
triggers:
# heartbeat -> health check
health:
on: heartbeat
workflow: ./workflows/health-check.yaml
on_workflow_failure: ignore
# hourly -> review with evaluate
hourly-review:
on: hourly
workflow: ./workflows/code-review.yaml
cooldown: "30m"
evaluate:
worker: CUSTOM
instructions: |
if git rev-parse --git-dir > /dev/null 2>&1; then
RECENT=$(git log --since="1 hour ago" --oneline 2>/dev/null | wc -l)
if [ "$RECENT" -gt 0 ]; then
exit 0
fi
fi
exit 1
capabilities: [READ, RUN_COMMANDS]
timeout: "10s"
context:
last_result: true
event_payload: true
# API change -> run tests
api-change:
on: api-status
workflow: ./workflows/test-suite.yaml
debounce: "30s"
cooldown: "5m"
max_retries: 1
on_workflow_failure: retryKey points:
max_concurrent_workflows: 2limits concurrency to 2- three event source kinds each drive different triggers
command.trigger_on: changefires only when API response output changes- each trigger can have different failure handling (
ignore/retry)
- See
docs/guide/workflow.mdfor writing workflows - See
docs/daemon-design.mdfor design details - Sample configs live in
examples/daemon/