playbook-webhooks-and-post-api-design.md30.7 KBView on GitHub # Playbook Webhooks (inbound), Post API component & Email-me (outbound) — Design
Three complementary capabilities for playbook agents:
1. **Webhook trigger (inbound)** — a new `webhook` trigger type. Adding it mints a public
`POST` URL the user copies. Any request to that URL fires the agent (async, payload-only).
2. **Post API component (outbound)** — a configurable **document block** (like the trigger
block) where the user predefines a name, endpoint URL, headers/secrets, and AI-described
body fields, serialized into the playbook XML. No dedicated Mastra tool or stored
connection: the agent fires the `POST` via the **existing code-executor subagent
capability** (`external-system-integration` skill → `run-code-executor`).
3. **Email-me tool (outbound)** — a Mastra tool the agent calls to email the user (e.g. "email
me a summary"), sent from `reminders.cedarcopilot.com` over the **same Resend infrastructure
as the reminder-email feature**.
**Locked decisions**
- Webhook firing: **async** — validate, enqueue to SQS, return `202`; a worker runs the agent.
- Webhook context: **conversationless** — the POST body is the only event context (no CRM conversation).
- Post API: a **doc-only block component** (config in `data-*` attrs → `<post-api …>` XML), with **no tRPC route, no `connection` row, and no dedicated tool**. At runtime the compiled block is rendered into the agent's prompt and the agent fires the `POST` through the existing `external-system-integration` skill / `run-code-executor` subagent.
- `/` and `#` menu order is canonical: **Trigger, Slack, iMessage, MCP**, then the rest (Post API, Web search, Enrich, Email, Subagent).
- Email-me: reuse the reminder **Resend** primitive; send immediately from the tool (no schedule), from `<email>`; recipient is the user's own account email. A dedicated `agent.cedarcopilot.com` sender remains the preferred end state but is blocked on a Resend plan upgrade — see the Sender domain section.
---
## Current state
### Triggers flow (the inbound analog: cron)
Trigger types today: `event_occurred`, `cron`, `before_meeting`, `conversation_change`.
- Editor: [TriggerNode.tsx](../modules/documents/playbook/TriggerNode.tsx) renders a block whose config is `TriggerConfig` (from [TriggerConfigEditor.tsx](../modules/aop/components/TriggerConfigEditor.tsx)); pills come from `TRIGGER_PILL_OPTIONS`.
- Serialize: [serialize-playbook-xml.ts](../../server/src/services/document-saving/serialize-playbook-xml.ts) turns the `triggerNode`'s `data-trigger-config` into `<trigger type="…" …>` (cron/before-meeting/field-change each emit attributes; unknown types fall through to `<trigger type="x">`).
- Compile: [compile-playbook.ts](../../server/src/services/playbook/compile-playbook.ts) `extractSectionTriggers()` switch parses each `<trigger>` into `cronBlocks` / `beforeMeetingBlocks` / `fieldChangeBlocks` / `eventBlocks` on the `CompiledGlobalSection` / `CompiledStageSection` ([compiled-playbook-types.ts](../../server/src/services/playbook/compiled-playbook-types.ts)). A `PlaybookManifest` ([trigger-parser.ts](../../server/src/services/playbook/trigger-parser.ts)) is also produced with `cronSchedules`, `fieldWatchers`, `beforeMeetingConfigs`, `eventTypes`, and stored at `documents.metadata.playbook_manifest`.
- Runtime (cron): the scheduler in [cron-task-registry.ts](../../server/src/cron/cron-task-registry.ts) `processAopAutomations()` reads `getPlaybookCronConfigs()` ([manifest-utils.ts](../../server/src/services/playbook/manifest-utils.ts), queries `metadata.playbook_manifest.cronSchedules`), then for each due config calls `getPlaybookSection({ …, eventType: 'cron' })` and `runPlaybookSectionExecution({ …, triggerType: 'cron' })` ([playbook-execution-triggers.ts](../../server/src/services/playbook/playbook-execution-triggers.ts)), which selects the matching trigger blocks and runs each subagent via `runAgent()` with a `TriggerContext` carrying `eventData`.
### Inbound HTTP (the receiver analog: Slack / meeting webhooks)
- Hono app: [http/app.ts](../../server/src/http/app.ts) mounts raw routes (`/public`, `/internal`, `/slack/events`, `/sms/webhook`, `/webhooks/meeting-notes/:provider`, …) bridged from [api-entry.ts](../../server/src/runtime/api-entry.ts).
- Auth for unauthenticated external callers: shared-secret header (Turbopuffer, meeting providers) or HMAC + timestamp anti-replay (Slack, `verifySlackSignature`). Dedup uses an in-memory map + KV bucket.
- Enqueue: handlers push to SQS (`SendMessageCommand`, FIFO with `MessageGroupId` + `MessageDeduplicationId`). The worker ([worker-entry.ts](../../server/src/runtime/worker-entry.ts) `startSqsPollers`) polls and dispatches to [queue-handler.ts](../../server/src/worker/queue-handler.ts), a `switch (true)` on `batch.queue.startsWith('…')` (`thread-queue`, `send-email-queue`, `scheduled-agent-action-queue`, …).
### Outbound API (the analog: code-executor subagent)
The agent can already call arbitrary external HTTP endpoints — no per-endpoint tool needed:
- Skill: `external-system-integration` ([external-system-integration.ts](../../server/src/mastra/skills/integrations/external-system-integration.ts)) — "Connect to and call external systems (Salesforce, HubSpot, Stripe, databases, etc.). Load this skill, then delegate to `run-code-executor`."
- Tool: `run-code-executor` ([runCodeExecutorTool.ts](../../server/src/mastra/tools/integrations/runCodeExecutorTool.ts)) — runs bash/Node in a code-executor subagent that performs the actual API call (auth, request, response handling).
- Identity: tools read runtime identity from `context.requestContext.get('userId' | 'orgId' | 'runId')`.
So Part 2 needs **no new tool, tRPC route, or connection storage** — only a doc component that carries the endpoint spec into the agent's prompt, which the agent hands to `run-code-executor`.
### Editor chips (the config-UI analog)
Two component shapes exist: [TriggerNode.tsx](../modules/documents/playbook/TriggerNode.tsx) is a full **block node** storing JSON config in `data-trigger-config`; [IntegrationNode.tsx](../modules/documents/playbook/IntegrationNode.tsx) renders inline `#` chips (`slack`, `imessage`, `mcp`, `web_search`, `enrich`) with `data-integration-{kind,config,label}`. Both are inserted via the `#` menu ([HashMentionList.tsx](../modules/documents/playbook/HashMentionList.tsx)) and `/` menu ([playbookExtensions.ts](../modules/documents/playbook/playbookExtensions.ts)). The MCP chip opens a config **dialog**, but it is **doc-only** (free-text, no backend). Post API reuses this doc-only dialog shape — its config lives entirely in the doc/XML, read by the agent at runtime.
---
## Part 1 — Webhook trigger (inbound)
### Data model
New table `playbook_webhook` ([db/aop-schema.ts](../../server/src/db/aop-schema.ts)):
```ts
playbook_webhook {
id: text pk // webhookId — embedded in the doc + compiled block
token=[redacted] unique // URL secret=[redacted] (rotatable)
userId: text -> user
orgId: text | null
aopId: text | null // user-scope AOP
orgAopId: text | null // org-scope AOP
scope: 'user' | 'org'
label: text | null // user-facing name for the endpoint
secret=[redacted] | null // optional HMAC shared secret (encrypted)
enabled: boolean default true
lastTriggeredAt: timestamp | null
createdAt, updatedAt
}
// index on token (lookup), (aopId), (userId)
```
`token` (URL) is decoupled from `id`/`webhookId` (doc + manifest) so the URL can be rotated without editing the playbook. `token` = 32 random bytes hex (unguessable; URL is the credential, matching standard catch-hook practice). Optional `secret` enables HMAC verification for callers that support signing.
### Trigger config
`TriggerConfig` gains a variant:
```ts
| { type: 'webhook'; webhookId: string; conversationFieldFilters?: … }
```
The block stores `webhookId`; the URL is resolved/displayed from the `playbook_webhook` row (not stored in the doc).
### Serialized form
```xml
<trigger type="webhook" id="wh_abc123">
…prose + @refs the agent runs…
</trigger>
```
### Compiled form
`CompiledWebhookBlock { webhookId: string; nodes: CompiledNode[] }` added to `CompiledGlobalSection` / `CompiledStageSection` (`webhookBlocks: CompiledWebhookBlock[]`), and `PlaybookManifest.webhookConfigs: Array<{ enabled: true; webhookId: string; stage: string | null }>` (so `getPlaybookWebhookConfigs()` can resolve a `token` → AOP without recompiling).
### Inbound endpoint + execution
```
POST /webhooks/playbook/:token (public Hono route on the api app)
1. capture raw body (text) + headers
2. look up playbook_webhook by token; 404 if missing, 403 if disabled
3. if row.secret present: verify HMAC (X-Cedar-Signature + X-Cedar-Timestamp,
±5 min anti-replay) — reuse the verifySlackSignature shape
4. dedupe (in-memory map + KV bucket) on a caller-supplied id or hash(body)
5. enqueue PlaybookWebhookMessage to playbook-webhook-queue (FIFO,
MessageGroupId=webhookId, dedup id) ; stamp lastTriggeredAt (best effort)
6. return 202 { accepted: true }
```
```ts
type PlaybookWebhookMessage = {
webhookToken=[redacted]; webhookId: string;
userId: string; orgId: string | null;
aopId: string | null; orgAopId: string | null;
scope: 'user' | 'org';
payload: unknown; // parsed JSON body (or { raw } if not JSON)
receivedAt: string;
};
```
Worker case `playbook-webhook-queue` in [queue-handler.ts](../../server/src/worker/queue-handler.ts):
```ts
const section = await getPlaybookSection({ userId, orgId, aopId, eventType: 'webhook', currentStage: null, … });
if (!section) return ack();
await runPlaybookSectionExecution({
userId, orgId, aopId, orgAopId,
conversationId: undefined, // conversationless
triggerType: 'webhook', webhookId,
playbookSection: section,
eventData: { source: 'webhook', webhook: { id: webhookId, payload, receivedAt } },
env,
});
```
`runPlaybookSectionExecution` gains a `webhook` branch: filter `webhookBlocks` (global + stage) by `webhookId`, collect their nodes, run subagents. `PlaybookExecutionParams.triggerType` adds `'webhook'` and a new optional `webhookId`. `getPlaybookSection` maps `eventType: 'webhook'` to the webhook blocks.
### tRPC surface
On `aop` router ([aop.ts](../../server/src/trpc/routes/aop.ts)):
- `createPlaybookWebhook({ aopId, scope, label? }) -> { webhookId, token, url }` — mints the row; `url = ${PUBLIC_BASE_URL}/webhooks/playbook/${token}`.
- `getPlaybookWebhook({ webhookId }) -> { url, enabled, lastTriggeredAt, hasSecret }` — for the editor to display/copy. Never returns `secret`.
- `rotatePlaybookWebhookToken({ webhookId })`, `setPlaybookWebhookEnabled({ webhookId, enabled })`, `setPlaybookWebhookSecret({ webhookId, secret? })`.
### Editor UX
When the user picks **Webhook** in the trigger pills, `TriggerNode` (type `webhook`) calls `createPlaybookWebhook` on first selection (if no `webhookId` yet), stores `webhookId`, and shows a copyable **POST URL** + curl example, an enable toggle, and "rotate URL". `triggerBadge()` renders `on: webhook`. Other event-specific config is hidden for this type.
### Touch points (Part 1)
| Area | File | Change |
|---|---|---|
| Frontend type + pill | `apps/mail/modules/aop/components/TriggerConfigEditor.tsx` | add `webhook` to `TriggerConfig`, `TRIGGER_TYPES`, `TRIGGER_PILL_OPTIONS`; render webhook panel (URL/copy/rotate/toggle) |
| Trigger badge | `apps/mail/modules/documents/playbook/TriggerNode.tsx` | `triggerBadge()` webhook case; call `createPlaybookWebhook` on first pick |
| XML serialize | `apps/server/src/services/document-saving/serialize-playbook-xml.ts` | emit `<trigger type="webhook" id="…">` |
| Compile | `apps/server/src/services/playbook/compile-playbook.ts` | `webhook` case in `extractSectionTriggers`; thread `webhookBlocks` |
| Compiled types | `apps/server/src/services/playbook/compiled-playbook-types.ts` + mail mirror | `CompiledWebhookBlock`, `webhookBlocks`, manifest `webhookConfigs` |
| Manifest | `apps/server/src/services/playbook/trigger-parser.ts` | parse webhook into `webhookConfigs` |
| Runtime dispatch | `apps/server/src/services/playbook/playbook-execution-triggers.ts` + `get-playbook-section.ts` | `webhook` branch; `triggerType`/`webhookId` params; `eventType: 'webhook'` |
| Trigger enum | `apps/server/src/db/aop-schema.ts` / `services/aop/automation-types.ts` | add `WEBHOOK` / `webhook` variant + zod |
| DB | `apps/server/src/db/aop-schema.ts` | `playbook_webhook` table (+ migration) |
| Public route | `apps/server/src/http/app.ts` (or `routes/playbook-webhook.ts`) | `POST /webhooks/playbook/:token` |
| Queue | `worker-entry.ts` config + `worker/queue-handler.ts` case + enqueue helper | `playbook-webhook-queue` (+ `AWS_SQS_PLAYBOOK_WEBHOOK_QUEUE_URL`, AWS CDK) |
| tRPC | `apps/server/src/trpc/routes/aop.ts` | create/get/rotate/enable/secret mutations |
---
## Part 2 — Post API component (outbound)
A configurable document block (config in the doc/XML, no backend storage). The agent reads the
compiled spec and fires the `POST` through the existing `external-system-integration` /
`run-code-executor` subagent — so there is **no Mastra tool, tRPC route, or connection row**.
### Component shape
A block node `PostApiNode` (modeled on `TriggerNode`, not the inline chips, because it carries
structured multi-field config), with JSON config in a `data-post-api-config` attribute:
```ts
type PostApiFieldDef = {
key=[redacted];
source: 'static' | 'ai'; // static = user-fixed value; ai = agent fills
value?: string; // for source:'static'
type?: 'string' | 'number' | 'boolean'; // for source:'ai'
description?: string; // for source:'ai' — guides the agent
required?: boolean;
};
type PostApiConfig = {
name: string; // user-facing endpoint name
endpointUrl: string;
headers?: Array<{ key=[redacted]; value: string }>;// e.g. Authorization
fields: PostApiFieldDef[]; // the body template
instructions?: string; // when/why to call this endpoint
};
```
The user's example maps directly: `secret=[redacted] string>` → `{ key=[redacted],
source:'static', value }`; `blabla: <type>` → `{ key=[redacted], source:'ai', type, description }`.
> **Security note (doc-only tradeoff).** Because config lives in the doc, header values and
> `static` field values are stored in the document XML as written (not encrypted at rest like MCP
> connection secrets). This is the explicit consequence of the doc-component approach. Encrypting
> static values / headers is a follow-up if these endpoints need real secrets; for now treat the
> playbook doc as the trust boundary (same as any prose the user types into it).
### Serialized form
`serialize-playbook-xml.ts` emits a `<post-api>` element from `data-post-api-config` (parallel to
the `<trigger>` path), with body fields as nested elements:
```xml
<post-api name="Notify CRM" url="https://api.example.com/hook">
<header key=[redacted] value="Bearer …"/>
<field key=[redacted] source="static" value="…"/>
<field key=[redacted] source="ai" type="string" description="short summary of the event"/>
<instructions>Call this whenever a deal moves to won.</instructions>
</post-api>
```
### Compiled form
`compile-playbook.ts` parses `<post-api>` into a `CompiledPostApiBlock { name, endpointUrl,
headers, fields, instructions }` on the section ([compiled-playbook-types.ts](../../server/src/services/playbook/compiled-playbook-types.ts), `postApiBlocks: CompiledPostApiBlock[]`). When a section is
assembled, `get-playbook-section.ts` renders any `postApiBlocks` into the prompt via the exported
`renderPostApiSection()` as a `<post_api_endpoints>` block: *"To call the **{name}** endpoint, load
the `external-system-integration` skill and use `run-code-executor` to POST to `{endpointUrl}` with
the listed headers and JSON body (static values inline; ai fields you fill per their description)."*
It is always included (a standing capability, not event-gated) and needs no manifest entry — the
block is inert until the agent acts on it.
### Runtime
No new runtime path: the agent already has `external-system-integration` →
[runCodeExecutorTool.ts](../../server/src/mastra/tools/integrations/runCodeExecutorTool.ts). The
compiled block is purely prompt context. Skill access is surfaced via the prompt directive above;
playbook agents run with empty `allowedSkills` (= all skills accessible per `aop_agents`), so they
can load `external-system-integration` on demand. A subagent with an explicitly restricted skill
list would need the skill added to its permissions — a follow-up, not needed for the common path.
### Editor UX
`PostApiNode` block + a config **dialog** (doc-only, no tRPC), inserted via the `#`/`/` menus.
Dialog fields: name, endpoint URL, repeatable headers (key/value), instructions, and a repeatable
body-field editor (key + `static`|`ai`; static→value input, ai→type + description). All writes go
straight to the node's `data-post-api-config`; reopening reads it back. The block renders a compact
summary (name + method/host + field count).
### Touch points (Part 2)
| Area | File | Change |
|---|---|---|
| Block node + dialog | `apps/mail/modules/documents/playbook/PostApiNode.tsx` (new) | block node storing `data-post-api-config`; config dialog |
| Editor wiring | `apps/mail/modules/documents/playbook/playbookExtensions.ts` + `HashMentionList.tsx` | register `post_api` insert in `#`/`/` menus (canonical order) |
| XML serialize | `apps/server/src/services/document-saving/serialize-playbook-xml.ts` | emit `<post-api …>` from `data-post-api-config` |
| Compile | `apps/server/src/services/playbook/compile-playbook.ts` | parse `<post-api>` → `postApiBlocks` (`extractSectionPostApi`) |
| Compiled types | `apps/server/src/services/playbook/compiled-playbook-types.ts` + mail mirror | `CompiledPostApiBlock`, `CompiledPostApiField`, `postApiBlocks` |
| Prompt render | `apps/server/src/services/playbook/get-playbook-section.ts` | `renderPostApiSection()` → `<post_api_endpoints>` directive (skill access surfaced here) |
---
## Part 3 — Email-me tool (outbound)
> **BUILT, THEN REMOVED (2026-08). Everything in this Part describes code that no longer
> exists** — `sendEmailNotificationTool` / `send-email-notification`, `env.AGENT_EMAIL_FROM`,
> `buildAgentEmailHtml`, the `notify.send-email` action and its approval-policy entry are all
> deleted, and the notifications skill now carries `notify-user` and `list-slack-channels`
> only.
>
> It was withdrawn on a behavioural finding, not a technical one. `notify-user` fails as a
> unit when every channel the user configured is down, and an email tool sitting beside it
> read to agents as the obvious fallback — so a broken Slack grant (`account_inactive`)
> became unrequested mail from Cedar into the user's own inbox, which no AOP had asked for.
> Notification delivery is now exactly the channels the user opted into, and a total failure
> stays a failure they can see and fix. Composing real mail remains `draft-comms`'s job,
> where it is an explicit act rather than a silent reroute.
>
> Retained as the record of the design and of why it was reversed. Parts 1 and 2 are
> unaffected.
The agent emails the user on request. We reuse the reminder-email send path verbatim, just
immediate (no schedule/KV) and from a different sender.
### Existing infrastructure (reused)
- Provider: `resend()` wrapper ([lib/services.ts](../../server/src/lib/services.ts)) — returns a no-op when `RESEND_API_KEY` is unset or `isProviderSideEffectsAllowed()` is false (non-prod safe), else a real `Resend` client.
- Send shape: the `send-remind-queue` worker calls `resend().emails.send({ from: 'Cedar Reminder <<email>>', to: [userEmail], subject, html, headers })` ([queue-handler.ts](../../server/src/worker/queue-handler.ts)).
- HTML: `buildReminderEmailHtml()` ([reminder-email-html.ts](../../server/src/services/reminders/reminder-email-html.ts)) — a small inline-styled string template.
- Stub to wire: `sendEmailNotificationTool` (id `send-email-notification`, "Send an email notification to the user's account email address", input `{ subject, body }`) already exists in [sendEmailNotificationTool.ts](../../server/src/mastra/tools/notifications/sendEmailNotificationTool.ts) with a `TODO: wire to email sender`.
### Sender domain
Sender `Cedar Agent <<email>>` (env `AGENT_EMAIL_FROM`, default that value), on the already-verified `reminders.cedarcopilot.com` domain.
A **distinct** `agent.cedarcopilot.com` subdomain remains the preferred end state, so agent mail and reminder mail hold independent SPF/DKIM/DMARC reputation — but it is not reachable today. Checked live 2026-08-08: the Resend account holds exactly one domain (`reminders.cedarcopilot.com`, verified, `us-east-1`) and `domains.create({ name: 'agent.cedarcopilot.com' })` returns `403 — "Your plan includes 1 domain. Upgrade to add more."` Until the plan is upgraded, agent and reminder mail share a sending reputation; revisit before agent mail carries meaningful volume.
### The tool
Wire the existing stub — send immediately (an agent "email me" expects confirmation, not a scheduled job, so no queue/KV needed):
```ts
// sendEmailNotificationTool.execute
const userId = context?.requestContext?.get('userId');
const runId = context?.requestContext?.get('runId');
if (!userId) return logged({ success: false, error: 'No userId in runtime context' });
const userEmail = await resolveUserEmail(userId); // user table / active connection email
if (!userEmail) return logged({ success: false, error: 'No email on file for user' });
const html = buildAgentEmailHtml(input.subject, input.body); // mirrors buildReminderEmailHtml
const result = await resend().emails.send({
from: env.AGENT_EMAIL_FROM ?? 'Cedar Agent <<email>>',
to: [userEmail],
subject: input.subject,
html,
headers: { 'X-Cedar-Agent-Email': runId ?? 'noRun' },
});
if (result.error) return logged({ success: false, error: 'send failed' });
return logged({ success: true, messageId: result.data?.id }); // logToolCall as today
```
`resolveUserEmail(userId)` — one indexed lookup (user table email, falling back to the user's primary connection email). `buildAgentEmailHtml` lives beside the reminder builder (e.g. `services/notifications/agent-email-html.ts`); escape `subject`/`body`, preserve line breaks. The `resend()` no-op path keeps non-prod/test runs side-effect-free automatically.
### Editor UX
Add an `email` `#`/`/` chip ("Email me", no config) in the same family as the recently-added `web_search` / `enrich` mention chips ([IntegrationNode.tsx](../modules/documents/playbook/IntegrationNode.tsx), [HashMentionList.tsx](../modules/documents/playbook/HashMentionList.tsx)) — purely a prose signal that the agent may email the user. The capability is the tool; the chip is discoverability. Register `send-email-notification` in a `notifications` skill so it's available via an agent's `allowedSkills`.
### Touch points (Part 3)
| Area | File | Change |
|---|---|---|
| Tool | `apps/server/src/mastra/tools/notifications/sendEmailNotificationTool.ts` | implement send via `resend()` + `resolveUserEmail` + `buildAgentEmailHtml` |
| HTML | `apps/server/src/services/notifications/agent-email-html.ts` (new) | inline-styled body template (mirror reminder builder) |
| Email resolve | `apps/server/src/services/notifications/resolve-user-email.ts` (new, or reuse a user-lookup helper) | `userId -> email` |
| Skill / register | `apps/server/src/mastra/skills/…` + `mastra/index.ts` | expose `send-email-notification` |
| Env | `apps/server/src/env.ts` | `AGENT_EMAIL_FROM` |
| Ops | Resend dashboard + DNS | verify `agent.cedarcopilot.com` (SPF/DKIM/DMARC) |
| Editor chip | `apps/mail/modules/documents/playbook/IntegrationNode.tsx` + `HashMentionList.tsx` + `playbookExtensions.ts` | add `email` kind chip (no config) |
---
## Menu ordering (`/` and `#`)
Today both menus order as: Slack, Subagent, Trigger, iMessage, MCP, Web search, Enrich
([HashMentionList.tsx](../modules/documents/playbook/HashMentionList.tsx) `HASH_MENTION_OPTIONS`,
[playbookExtensions.ts](../modules/documents/playbook/playbookExtensions.ts)
`createPlaybookSlashCommands()`). Reorder both lists to the canonical sequence:
1. **Trigger**
2. **Slack**
3. **iMessage**
4. **MCP**
5. Post API
6. Web search
7. Enrich
8. Email
9. Subagent
Both menus are driven by the same `HASH_MENTION_OPTIONS` shape, so reorder that array and mirror
the order in `createPlaybookSlashCommands()`. New entries (`post_api`, `email`) slot in at the
positions above.
## Phased implementation plan
### Phase 1 — Webhook: type plumbing (no firing yet)
- [x] Add `webhook` to `TriggerConfig` / `TRIGGER_TYPES` / pills; `triggerBadge()` case.
- [x] XML serialize `<trigger type="webhook" id>`; compile `webhook` case → `webhookBlocks`; compiled types (server + mail mirror) + manifest `webhookConfigs`.
- [x] `playbook_webhook` table + migration; trigger enum / automation-types variant.
- **Test:** unit — round-trip a `webhook` trigger block through serialize→compile and assert `webhookBlocks`/`webhookConfigs`; existing playbook compile tests still pass.
### Phase 2 — Webhook: endpoint + execution
- [x] tRPC `createPlaybookWebhook` / `getPlaybookWebhook` / rotate / enable / secret.
- [x] `POST /webhooks/playbook/:token` (lookup, optional HMAC, enqueue, 202). Dedupe is FIFO-group-by-`webhookId` only; content-dedup / per-token rate limiting deferred (see Security notes).
- [x] `playbook-webhook-queue` config + `queue-handler` case + enqueue helper + SQS env/CDK.
- [x] `runPlaybookSectionExecution` + `getPlaybookSection` `webhook` branch (`webhookId`, conversationless `eventData`).
- **Test:** integration — `createPlaybookWebhook` → `POST` the token → assert 202, a queue message, and (worker) `runPlaybookSectionExecution` invoked with `triggerType:'webhook'` + payload; 404 unknown token; 403 disabled; HMAC reject.
### Phase 3 — Webhook: editor UX
- [x] `TriggerNode` webhook panel: mint-on-pick, copyable URL + curl, enable toggle, rotate.
- **Test:** component — selecting Webhook shows a URL and hides event config; rotate updates the displayed URL. Manual: copy URL, `curl` it, confirm an execution row.
### Phase 4 — Post API: component + compile + prompt
- [x] `PostApiConfig` type; `<post-api>` XML serialize; compile `<post-api>` → `postApiBlocks` (server + mail mirror types).
- [x] Skill access surfaced via the `<post_api_endpoints>` prompt directive (playbook agents have empty `allowedSkills` = all skills, so they can load `external-system-integration`; explicit restricted-subagent injection deferred).
- [x] Render `postApiBlocks` into the assembled section via `renderPostApiSection()` in `get-playbook-section.ts`.
- **Test:** unit — round-trip a `<post-api>` block through serialize→compile and assert `postApiBlocks` (name/url/headers/fields); a section with a post-api block resolves the `external-system-integration` skill; the rendered prompt contains the endpoint instruction with static values inline and ai fields described. Integration — an agent run with the block POSTs to a stub server (via `run-code-executor`) with body == static + ai fields.
### Phase 5 — Post API: editor UX
- [x] `PostApiNode` block + doc-only config editor (URL, headers, body-field editor, instructions); insert via `#`/`/` menus. (Config is an inline expandable panel on the block, in the TriggerNode family, rather than a separate modal.)
- [x] Reorder `#`/`/` menus to the canonical sequence (Trigger, Slack, iMessage, MCP, Post API, Web search, Enrich, Subagent). Email is inserted between Enrich and Subagent in Phase 6.
- **Test:** component — saving the dialog writes `data-post-api-config` and reopening shows persisted config; the menus list options in canonical order. Manual: configure an endpoint, run the agent, observe the POST.
### Phase 6 — Email-me tool
- [x] Add `AGENT_EMAIL_FROM` env (default `Cedar Agent <<email>>`). **Ops:** none outstanding — `reminders.cedarcopilot.com` is already verified in Resend, so this sends today. The dedicated `agent.cedarcopilot.com` subdomain is blocked on a Resend plan upgrade (one-domain limit); see the Sender domain section.
- [x] `buildAgentEmailHtml` + `resolveUserEmail`; wire `sendEmailNotificationTool` to `resend()`; `notifications` skill already registers the tool; add the `email` editor chip (`#`/`/` menus).
- **Test:** unit — `execute` calls `resend().emails.send` with FROM = `AGENT_EMAIL_FROM`, TO = resolved user email, escaped html; missing-email and `result.error` paths return `success:false` and log; the `resend()` no-op path makes the test assert intent without sending. Manual (prod-like): a playbook saying "email me a summary" runs and the account inbox receives mail from `<email>`.
---
## Verification steps (end-to-end)
1. **Inbound:** add a Webhook trigger to a playbook with prose like "summarize the payload and Slack me"; save; copy the URL; `curl -X POST -d '{"hello":"world"}'`; confirm `202`, a queued message, and an agent execution whose `eventData.webhook.payload` is the body; disable → `403`; rotate → old URL `404`.
2. **Outbound (Post API):** add a Post API block (one `static` field + one `ai` field) pointing at a request-bin; save and confirm the doc serializes a `<post-api>` element; in chat/event execution ask the agent to call it; confirm the bin received `{ secret=[redacted] value>, <aiKey>: <agent value> }` and the `run-code-executor` call was logged.
3. **Outbound (Email):** a playbook instructing "email me …" runs; the user's account inbox receives a message from `Cedar Agent <<email>>` with the agent's subject/body; the tool call is logged; in non-prod the `resend()` no-op fires (no real send).
4. **Regression:** existing cron/before-meeting/field-change/event triggers compile and fire unchanged; the reminder-email path is untouched (separate sender domain + send path); `pnpm deps:check` passes (no skill→agent or service→tool cycles introduced).
## Security notes
- Webhook URL token is the credential (32-byte random); optional HMAC `secret` for signing callers; ±5-min timestamp anti-replay; dedupe to blunt retries/floods. (Per-token rate limiting is a follow-up — none exists today.)
- Post API: config (endpoint, headers, `static` values) lives in the playbook doc/XML — the doc is the trust boundary, and values are **not encrypted at rest** (unlike MCP connection secrets). The agent fires the request through the sandboxed `run-code-executor`; `static` values are authored by the user, `ai` values by the agent. Encrypting headers/static values is a follow-up if real secrets are needed; SSRF is bounded by the code-executor's own egress controls rather than a per-endpoint validator.