Tutorial: Build an Automation
Build a deployment monitor that receives webhooks, spawns subagents to analyze work, emits progress events, and schedules follow-up tasks. By the end you'll understand how webhooks are verified and dispatched, how subagents run with their own tool sets and structured returns, how the event system connects decoupled skills, and how scheduled tasks fire in the future.
What You'll Build
A deployment monitor that:
- Receives a webhook when a deploy starts
- Spawns a subagent to analyze the deployment for risks
- Emits progress events that other skills observe
- Schedules a health check 5 minutes after deployment
- Saves analysis reports to disk
Prerequisites
- Node.js 22+
- AWS credentials with Bedrock access — see AWS & Bedrock Setup
Step 1: Create the Project
mkdir deploy-monitor && cd deploy-monitor
agent-apps init
Update main.skill.md:
---
name: deploy-monitor
metadata:
role: main
paths: [./skills]
model:
id: us.anthropic.claude-sonnet-4-6
region: us-east-1
webhook:
routes:
- path: /hooks/deploy
secret: whsec_test123
ref: on-deploy
schedule:
---
What Each Config Key Does
webhook.routes — Configures webhook endpoints as a list of routes:
path: /hooks/deploy— the URL path that receives webhookssecret: whsec_test123— HMAC secret for Standard Webhooks signature verification. In production, use an environment variable:AGENT_APPS_LOCAL_WEBHOOK__ROUTES__0__SECRET=whsec_...ref: on-deploy— the skill to invoke when a verified webhook arrives
schedule: — Enables the schedule system. With no value (null), it uses file persistence by default. Schedules survive process restarts.
Step 2: Handle the Webhook
Create skills/on-deploy.skill.js:
export const frontmatter = {
name: 'on-deploy',
description: 'Handle deploy webhook and orchestrate analysis',
metadata: {
params: {
type: 'object',
properties: {
service: { type: 'string', description: 'Service name' },
version: { type: 'string', description: 'Version being deployed' }
}
}
}
};
export default async function(ctx, args) {
const deploy = args || {};
// 1. Announce the deployment
await ctx.manager.invoke('event-emit', {
topic: 'deploy-progress',
type: 'started',
data: { service: deploy.service, version: deploy.version }
});
// 2. Spawn a subagent to analyze risks
const analysis = await ctx.manager.invoke('agent', {
prompt: `Analyze this deployment and identify potential risks:\n${JSON.stringify(deploy, null, 2)}`,
allowedTools: 'file-read web-fetch',
returns: {
type: 'object',
properties: {
risk: { type: 'string', description: 'low, medium, or high' },
notes: { type: 'array', items: { type: 'string' }, description: 'Risk factors' }
},
required: ['risk']
}
});
// 3. Emit the analysis result
await ctx.manager.invoke('event-emit', {
topic: 'deploy-progress',
type: 'analyzed',
data: { service: deploy.service, analysis }
});
// 4. Schedule a health check in 5 minutes
await ctx.manager.invoke('schedule-create', {
ref: 'health-check',
delay: '5m',
args: { service: deploy.service, version: deploy.version },
note: `Post-deploy check for ${deploy.service}`
});
// 5. Save the report
await ctx.manager.invoke('file-write', {
path: `reports/${deploy.service}-${Date.now()}.json`,
content: JSON.stringify({ deploy, analysis }, null, 2)
});
return { status: 'processed', risk: analysis.risk };
}
How Webhook Dispatch Works
When a POST request arrives at /hooks/deploy:
The
webhookmiddleware verifies the Standard Webhooks signature. It checks thewebhook-id,webhook-timestamp, andwebhook-signatureheaders against the configured secret using HMAC-SHA256. Invalid signatures are rejected with 401.It checks for replay attacks — the timestamp must be within 5 minutes of the current time.
It matches the request path against the
routes:config to find the target skill.It invokes the matching skill with the parsed payload directly as
args.
Webhooks are fire-and-forget — there's no reply channel. The webhook sender gets a 200 response immediately; the skill processes asynchronously. This is different from email and Slack, where the gateway enables replies.
How Subagents Work
The ctx.manager.invoke('agent', { prompt, allowedTools, returns }) call spawns a subagent:
prompt— The subagent's system prompt. It describes the task.allowedTools: 'file-read web-fetch'— The subagent can only use these tools. It can't write files, run shell commands, or call other skills. This is how you sandbox a subagent.returns— A JSON Schema the subagent must satisfy. The agent must callctx.manager.finish(value)with data matching this schema. If it doesn't, the hook callback re-invokes with a reminder. This is how you get structured data from an LLM.
The subagent gets its own LLM session, its own tool set, and its own context. It runs the full pipeline — cascade, middleware, everything. When it finishes, the result is returned to the caller.
To prevent the agent from spawning subagents with broader tool access than you intend, constrain the agent tool's allowedTools arg in your skill's allowed-tools. In this example the calling skill uses allowed-tools: "*" so there's no restriction, but a more locked-down skill would write agent(allowedTools=file-read web-fetch) to ensure the subagent can only receive those specific tools.
The returns schema is enforced: if the subagent returns { risk: "high" } without notes, that's valid (notes isn't required). If it returns "high risk" (a string, not an object), the schema validation fails and the agent gets another turn to fix it.
Step 3: The Health Check
Create skills/health-check.skill.js:
export const frontmatter = {
name: 'health-check',
description: 'Check service health after deployment',
metadata: {
params: {
type: 'object',
properties: {
service: { type: 'string' },
version: { type: 'string' }
}
}
}
};
export default async function(ctx, { service, version }) {
// In a real app, you'd check the service's health endpoint
const healthy = true;
await ctx.manager.invoke('event-emit', {
topic: 'deploy-progress',
type: 'health-check',
data: { service, version, status: healthy ? 'healthy' : 'unhealthy' }
});
return { service, status: healthy ? 'healthy' : 'unhealthy' };
}
How Scheduling Works
When on-deploy calls schedule-create:
await ctx.manager.invoke('schedule-create', {
ref: 'health-check', // skill to invoke
delay: '5m', // when to fire (also accepts: 30s, 2h, 1d)
args: { service, version }, // args to pass
note: 'Post-deploy check' // human-readable label
});
The schedule middleware creates a timer (setTimeout). After 5 minutes, it invokes health-check through globals.runtime.invoke — the top-level invoker that works outside any pipeline context. The scheduled invocation gets its own full pipeline, just like a CLI invocation.
Schedules persist to .agent-apps/schedules.json by default (store: file). They survive process restarts — on startup, the schedule middleware reloads persisted entries and rearms their timers. Use store: memory for process-lifetime-only schedules.
You can manage schedules:
agent-apps schedule-list # see pending schedules
agent-apps schedule-cancel --id sch_abc123 # cancel one
The delay argument accepts human-readable strings: 30s, 5m, 2h, 1d. Alternatively, use at with an ISO timestamp: at: "2026-04-01T09:00:00Z".
Step 4: Observe Progress
Create a skill that reacts to deploy events:
Create skills/notify.skill.js:
export const frontmatter = {
name: 'notify',
description: 'Log deploy progress events',
};
export default async function(ctx, event) {
const timestamp = new Date(event.timestamp).toISOString();
const message = `[${timestamp}] ${event.type}: ${JSON.stringify(event.data)}`;
console.log(message);
// In a real app, you might send to Slack, email, or a dashboard
// await ctx.manager.invoke('slack-send', { channel: '#deploys', message });
}
How Events Connect Skills
The event system is a decoupled pub/sub bus:
event-emitpublishes an event to a named topic. Events have atype, optionalsource, optionaldata, and an auto-assignedidandtimestamp.event-subscribesubscribes programmatically (from code). You can subscribe a skill ref or create a queue forevent-wait.event-waitblocks until an event arrives. Useful for coordination between skills.
The event middleware's routes: config on the main skill subscribes skills to topics declaratively. When an event arrives on deploy-progress, the notify skill is invoked with the event envelope as args.
Events are fire-and-forget by default — event-emit doesn't wait for subscribers to finish. This keeps the emitter fast. Subscribers run asynchronously in their own pipeline contexts.
The event flow in this tutorial:
on-deploy
→ event-emit("deploy-progress", "started") → notify logs it
→ agent (analyze)
→ event-emit("deploy-progress", "analyzed") → notify logs it
→ schedule-create("health-check", 5m)
[5 minutes later]
health-check
→ event-emit("deploy-progress", "health-check") → notify logs it
Each skill is independent. on-deploy doesn't know about notify. health-check doesn't know about notify. They communicate through events. You can add more subscribers (Slack notifications, dashboard updates, audit logs) without changing the emitters.
Step 5: Run It
agent-apps
The webhook server starts. Test with curl by temporarily setting an empty secret for local development:
# In main.skill.md, for local testing only:
webhook:
routes:
- path: /hooks/deploy
secret: ""
ref: on-deploy
curl -X POST http://localhost:8080/hooks/deploy \
-H "content-type: application/json" \
-H "webhook-id: test-1" \
-H "webhook-timestamp: $(date +%s)" \
-H "webhook-signature: v1," \
-d '{"service": "api", "version": "2.1.0"}'
Note: With an empty secret, signature verification is skipped. In production, use a real secret and compute the HMAC signature. See the Standard Webhooks spec for signature computation details.
You'll see:
[2026-03-25T...] started: {"service":"api","version":"2.1.0"}
⠋ Thinking... (subagent analyzing)
✓ agent — Completed in 8.2s
[2026-03-25T...] analyzed: {"service":"api","analysis":{"risk":"low","notes":["Standard version bump"]}}
Schedule created: sch_a1b2c3d4 (fires in 5m)
[5 minutes later]
[2026-03-25T...] health-check: {"service":"api","version":"2.1.0","status":"healthy"}
What You Built
deploy-monitor/
main.skill.md ← Main skill: webhook + schedule config
skills/
on-deploy.skill.js ← Webhook handler: analyze + schedule
health-check.skill.js ← Scheduled health check
notify.skill.js ← Event observer: logs progress
reports/ ← Analysis reports (created at runtime)
What You Learned
- Webhooks verify signatures automatically. The
webhookmiddleware checks Standard Webhooks HMAC signatures and rejects invalid requests. No verification code needed in your skill. - The
webhookmiddleware dispatches to handler skills. Therefin the routes array names the skill. Webhooks are fire-and-forget — no reply channel. - Subagents run with their own tool sets.
ctx.manager.invoke('agent', { prompt, allowedTools, returns })spawns an isolated agent with restricted tools and enforced return schemas. returnsenforces structured output. The agent must callctx.manager.finish(value)with data matching the JSON Schema. Mismatches trigger a retry.- Events decouple skills.
event-emitpublishes, theeventmiddleware'sroutes:config subscribes. Emitters and subscribers don't know about each other. Add more subscribers without changing emitters. schedule-createdefers work. In-memory timers that fire skill invocations in the future. Accepts delay strings (30s,5m,2h) or ISO timestamps. Schedules persist to disk by default and survive process restarts.- All these patterns compose. Webhooks trigger code that spawns agents that emit events that trigger more skills that schedule future work. Each piece is a skill with one interface.
Next Steps
- Web App Tutorial — HTTP routes, WebSocket, AI endpoints
- Chat Agent Tutorial — Persistent conversational agent
- Cookbook — Events — Event patterns and manual acknowledgment
- Cookbook — Schedule — Scheduling details
- Cookbook — Agent — Subagent coordination patterns