← All posts

How to build a production support reply system with Vocion

A 400-line walkthrough of the L1 support-reply demo — two operations, one workflow, one card. Ticket in, AI summary + draft, human approval, sent envelope. Plus what to change to take it to production.

Vocion Teamvocion-v0.4.0

The fastest way to understand a framework is to see one real thing built end-to-end with it. This post walks through the support-reply demo that ships with vocion-demos@v0.4.0 — a help-desk triage workflow that turns an inbound ticket into a drafted reply, lands the draft in a human review queue, and "sends" it through a pluggable Card UI.

The demo is small on purpose. Two operations, one workflow, one custom Card, eight seed tickets. About 400 lines of YAML and markdown total. But it exercises every layer the framework cares about: typed operations, durable workflow runs, human-in-the-loop approval, observability traces, and the new pluggable Cards system that landed in this release.

The shape

ticket  →  summarize_ticket  →  draft_reply  →  [approve gate]  →  send (Card)
            (Haiku, ~400ms)     (Sonnet, requires approval)        (stub envelope)

Three pieces of content under vocion-demos/demos/support-reply/context/support-demo/:

operations/
  summarize-ticket/{skill.yaml, prompt.md}
  draft-reply/{skill.yaml, prompt.md}
workflows/
  support-triage/workflow.yaml
sample-tickets/
  T-1001-shipping-delay.md  ...  T-1008-thank-you.md

No TypeScript required. The runtime loads these on boot and exposes them to the dashboard at /dashboard/workflows/support_triage.

Operation 1 — summarize_ticket

Fast classification. Haiku 4.5, low temperature, no approval required.

# operations/summarize-ticket/skill.yaml
slug: summarize_ticket
name: Summarize Support Ticket
model: claude-haiku-4-5-20251001
temperature: '0.2'
requiresApproval: false
promptFile: prompt.md
inputSchema:
  type: object
  required: [ticket]
  properties:
    ticket: { type: string }
    customer_name: { type: string }

The prompt is plain markdown with {{input.field}} interpolation:

You are triaging a support ticket. Produce a tight 2-3 sentence summary
that a human support agent could read in 5 seconds.

Cover, in order:
  1. The core issue
  2. Relevant technical detail
  3. Desired resolution

{{#if input.customer_name}}**Customer:** {{input.customer_name}}{{/if}}

**Ticket:**

{{input.ticket}}

That's it. The runtime handles model selection, retries, cost tracking via agent_budget, and Langfuse tracing. Author the prompt, ship the YAML, you have a typed callable operation.

Operation 2 — draft_reply

Same shape, different model. Sonnet 4.6 for the actual drafting, requiresApproval: true so the output lands in the review queue instead of auto-sending.

slug: draft_reply
model: claude-sonnet-4-6
temperature: '0.4'
requiresApproval: true        # ← this is the whole HITL story
inputSchema:
  required: [summary, ticket]
  properties:
    summary: { type: string }       # output of summarize_ticket
    ticket: { type: string }        # original, for tone matching
    customer_name: { type: string }
    sender_name: { type: string }

The prompt has explicit guardrails:

Never promise refunds, escalations to legal, or SLA commitments — those need human approval (this reply lands in the review queue regardless, but don't put words in the company's mouth).

requiresApproval: true is what makes this production-shaped: a draft is generated, captured, traceable, and waiting for a human — never silently sent.

The workflow

# workflows/support-triage/workflow.yaml
slug: support_triage
inputSchema:
  required: [ticket]
  properties:
    ticket:        { type: string }
    customer_name: { type: string }
    sender_name:   { type: string, default: 'the Support team' }
steps:
  - name: summary
    type: skill
    skill: summarize_ticket
    input:
      ticket: '{{input.ticket}}'
      customer_name: '{{input.customer_name}}'

  - name: draft
    type: skill
    skill: draft_reply
    input:
      summary: '{{steps.summary.output}}'
      ticket: '{{input.ticket}}'
      customer_name: '{{input.customer_name}}'
      sender_name: '{{input.sender_name}}'

  - name: review
    type: approve
    reviews: draft

  - name: send
    type: action
    action: stub.log_only
    input:
      to_customer: '{{input.customer_name}}'
      body_from_step: draft

Four steps. The approve step pauses the run; the action step records intent and emits a structured envelope the new Cards system renders as a "would-be-sent" email card.

The Card

v0.4.0 ships a Cards SDK as the first-class way to render workflow outputs. The send action above emits:

{ __card: 'send-stub',
  stubbed: true,
  envelope: { to, subject, body, sent_at, action } }

The run-detail page calls resolveCard() on each step's output, which looks up the send-stub first-party Card and renders an email envelope (subject, to, body, "Stub" badge) instead of a JSON blob. Downstream apps can ship their own Cards via PluginManifest.renderers; slug priority resolves collisions so support-reply.sent-email would override send-stub for support-reply runs without touching the framework.

Try it locally

About two minutes from git clone to a streamed workflow run:

git clone --recurse-submodules https://github.com/vocion/vocion-local.git
cd vocion-local
./bootstrap.sh
docker compose up -d
cd vocion-demos/demos/support-reply && ./scripts/dev.sh

Then:

  1. Open http://localhost:3001/sign-in — the banner autofills demo@example.com / demo123
  2. Navigate to /dashboard/workflows/support_triage
  3. Click Run workflow, paste a ticket body (or copy T-1001-shipping-delay.md)
  4. Watch the stepper light up: summary → draft → paused for approval
  5. Approve in /dashboard/review
  6. Land on the run-detail page; the final step shows the rendered email envelope

Every step has a Langfuse trace; check /dashboard/observability for cost + latency per call.

From stub to production

The demo's action: stub.log_only is intentional — v0.4.0 ships the surface, not the integration. To take it to production, three swaps:

  1. Real send — replace the stub action with a Gmail/Sendgrid/Zendesk handler. Action handlers are typed; one file in src/services/temporal/activities/. The Card stays the same; the Stub badge flips to Sent.
  2. Cost ceilings — wire an agent_budget row for the demo's account. Hard caps refuse new runs when over budget; soft caps emit a warning. Already available; off by default.
  3. Tenant cards — ship your org's branded sent-email Card via PluginManifest.renderers. Same Cards SDK; slug priority overrides the generic send-stub for your runs.

The full reference guide at /docs/guides/building-a-support-reply-system goes deeper: error handling, multi-tenancy, observability tags, and what changes when you build a different L1 demo from the same template.