Draft with an agent, then gate the send on a human

This flow uses all three building blocks at once. A workflow asks an agent to draft a reply, pauses for a person to approve it, and sends only once someone approves. The send lives in its own step behind the approval, so a rejected run never reaches it.

The whole flow is a single workflows/draft-and-send.workflow.ts file. The agent's reply arrives as plain text, the same as in the triage example, so draftReply parses and validates it before the workflow uses it.

workflows/draft-and-send.workflow.ts
import { z } from '@stackbone/sdk';
import { callDeepAgent, requestApproval } from '@stackbone/sdk/workflow';

export const inputSchema = z.object({
  to: z.email(),
  topic: z.string(),
});
export const outputSchema = z.object({
  to: z.string(),
  sent: z.boolean(),
  draft: z.string(),
});

const draftSchema = z.object({ subject: z.string(), body: z.string() });

export async function draftAndSendWorkflow(input: z.infer<typeof inputSchema>) {
  'use workflow';

  const draft = await draftReply(input.topic); // agent writes the draft

  const decision = await requestApproval({
    // No `token`: the SDK assigns a resume key unique to this run.
    topic: 'outbound-email',
    payload: { to: input.to, draft },
    title: 'Approve this email before it goes out',
    timeout: '24h',
    fallback: 'reject',
  });

  if (decision.status !== 'approved') {
    return { to: input.to, sent: false, draft: draft.body };
  }

  await send(input.to, draft); // gated side effect
  return { to: input.to, sent: true, draft: draft.body };
}

async function draftReply(topic: string) {
  'use step';
  const { text } = await callDeepAgent(
    'support',
    `Draft a short, friendly email about: ${topic}. ` +
      'Reply with JSON only: {"subject": string, "body": string}.',
  );
  return draftSchema.parse(JSON.parse(text));
}

async function send(to: string, draft: { subject: string; body: string }) {
  'use step'; // the side effect, gated behind the approval. Keep it idempotent
  return { to, subject: draft.subject, delivered: true };
}

Two details decide whether this holds up in production.

  • Leave token unset. The SDK then assigns a resume key unique to the run, so two drafts for the same recipient and topic cannot collide. A collision resolves to the fallback instead of waiting for a person. See when to pass your own token.
  • Keep send idempotent. The runtime re-runs a step that crashes part-way through, so give the outbound message a stable id and let a repeat call recognise a message it already delivered.

What's next

BUILT WITH ❤️ FROM CANADA AND SPAIN