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:

  1. Receives a webhook when a deploy starts
  2. Spawns a subagent to analyze the deployment for risks
  3. Emits progress events that other skills observe
  4. Schedules a health check 5 minutes after deployment
  5. Saves analysis reports to disk

Prerequisites

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 webhooks
  • secret: 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:

  1. The webhook middleware verifies the Standard Webhooks signature. It checks the webhook-id, webhook-timestamp, and webhook-signature headers against the configured secret using HMAC-SHA256. Invalid signatures are rejected with 401.

  2. It checks for replay attacks — the timestamp must be within 5 minutes of the current time.

  3. It matches the request path against the routes: config to find the target skill.

  4. 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 call ctx.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:

  1. event-emit publishes an event to a named topic. Events have a type, optional source, optional data, and an auto-assigned id and timestamp.

  2. event-subscribe subscribes programmatically (from code). You can subscribe a skill ref or create a queue for event-wait.

  3. event-wait blocks 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 webhook middleware checks Standard Webhooks HMAC signatures and rejects invalid requests. No verification code needed in your skill.
  • The webhook middleware dispatches to handler skills. The ref in 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.
  • returns enforces structured output. The agent must call ctx.manager.finish(value) with data matching the JSON Schema. Mismatches trigger a retry.
  • Events decouple skills. event-emit publishes, the event middleware's routes: config subscribes. Emitters and subscribers don't know about each other. Add more subscribers without changing emitters.
  • schedule-create defers 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

Ask AI