Building a support reply system
This guide is the long-form companion to the blog post on the L1 support-reply demo. The post walks through what's in the demo end-to-end in about 800 words; this guide goes deeper on the parts that matter when you take it to production: schema design, error handling, cost ceilings, real send integrations, multi-tenancy, and observability.
If you haven't already, run the demo locally first — the walkthrough is much more concrete once you've seen the workflow stream a real run.
Architecture at a glance
Three layers, in increasing distance from the user:
┌─ Dashboard (Next.js, /dashboard/workflows/...)
│ ↓
├─ Workflow runtime (Postgres + Temporal-or-inline executor)
│ ↓
└─ Operations (typed LLM calls — Haiku, Sonnet, etc.)
The support-reply demo touches all three:
- Dashboard — workflow runner form, run-detail page with the Cards SDK, approval queue at
/dashboard/review - Workflow runtime —
support_triagechains two operations + an approval gate + a send action, with durable state inworkflow_runrows - Operations —
summarize_ticket(Haiku) +draft_reply(Sonnet, requires approval)
Plus one architectural primitive that's new in v0.4.0:
- Cards SDK — typed React components that render structured outputs. The
sendaction's envelope is rendered via the first-partysend-stubCard; downstream apps register their own.
Authoring operations
An operation lives in workspace/<org>/operations/<slug>/ as two files:
skill.yaml— typed metadata (slug, model, schema, approval flag)prompt.md— the prompt with{{input.field}}interpolation
Example — summarize-ticket/skill.yaml:
slug: summarize_ticket
name: Summarize Support Ticket
description: Read a ticket → 2-3 sentence triage summary.
category: query
status: active
version: 1
model: claude-haiku-4-5-20251001
temperature: '0.2'
requiresApproval: false
promptFile: prompt.md
inputSchema:
type: object
required: [ticket]
properties:
ticket: { type: string, description: 'Raw ticket body' }
customer_name: { type: string }
Three principles for production-shaped operations:
- One operation, one prompt. Tempting to multi-step inside a single operation; resist. Use the workflow to chain operations so each step is independently traceable, retriable, and budgetable.
- Low-temp triage, higher-temp drafting.
summarize_ticketruns at0.2for stable classification;draft_replyat0.4for natural prose. Don't crank the temperature uniformly. requiresApproval: truefor anything customer-facing. The output lands in the review queue. Humans approve / edit / reject. The audit trail captures every decision back to the prompt version that produced it.
The runtime handles model selection, retries, cost tracking, Langfuse tracing, and input/output validation. The author writes the prompt.
Authoring the workflow
Workflows chain operations + approval gates + actions:
slug: support_triage
trigger:
type: manual # also: webhook, cron, agent
inputSchema:
type: object
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 step types are available:
skill— calls an operation. Inputs interpolated from{{input.*}}and{{steps.<name>.output}}.approve— pauses the run; references askillstep whose output the approver reviews.action— fires a side effect (today: stub-only; production: send email, fire webhook, etc.).branch— coming in v0.5; conditional routing based on previous step output.
The Cards SDK (new in v0.4.0)
The workflow's send action records its intent as a structured payload that includes a __card discriminator:
{
__card: 'send-stub',
stubbed: true,
envelope: { to, subject, body, sent_at, action }
}
The run-detail page calls resolveCard(stepOutput, { surface: 'workflow-run' }) for each step. The deterministic resolver reads __card, looks up send-stub in the registry, validates the envelope against the Card's Zod schema, and renders the typed component. No JSON blob.
Why this matters for production: downstream apps don't have to fork the framework to brand their UI. A support-reply tenant can ship support-reply.sent-email with severity coloring, refund-detection highlighting, and a custom action menu — via PluginManifest.renderers. Slug priority makes it win over the generic send-stub for support-reply runs without touching the framework.
// In a plugin manifest:
import { defineCard } from '@vocion/sdk';
import { z } from 'zod';
import { SupportReplySentEmail } from './SupportReplySentEmail';
export default {
id: 'support-reply',
version: '0.4.0',
renderers: [
defineCard({
slug: 'support-reply.sent-email',
name: 'Support reply (sent)',
description: 'Branded sent-email card with severity + refund-flag highlights.',
surfaces: ['workflow-run', 'review-queue'],
dataSchema: z.object({ /* ... */ }),
Renderer: SupportReplySentEmail,
}),
],
};
The full Cards SDK contract is at vocion-core/packages/sdk/src/cards.ts.
Taking it to production
1. Replace the stub send with a real integration
The demo's action: stub.log_only records an envelope but takes no side effect. Three pieces to swap:
- Activity handler —
vocion-core/packages/core/src/services/temporal/activities/index.tshasexecuteActionActivity(v1 stub). Replace with an action-handler-registry pattern: eachaction: <name>maps to a typed handler. - Credentials — Gmail / Sendgrid / Zendesk send needs OAuth or API keys. Pattern: reuse the Source SDK's credential storage (
libs/sources/) and add an "action credential" scope. - Card — flip
stubbed: truetofalsein the envelope; thesend-stubCard already changes the badge from "Stub" to "Sent." Or ship a tenant-specific card with theSentstate styled differently.
2. Cost ceilings
The agent_budget table caps token + dollar spend per period per account. Already wired into the operation runner; off by default. To enable:
INSERT INTO agent_budget (account_id, period, hard_cap_usd_cents, soft_cap_usd_cents)
VALUES ('acct_demo', 'daily', 500, 250);
Hard cap → new runs refused with a structured error. Soft cap → run completes, emits a warning, telemetry tagged for the team. Recommended for any public demo or trial tenant.
3. Multi-tenancy
Every operation, workflow, and Card resolves against the active project (per the v0.3 NextAuth + tenancy work). Two patterns:
- One project per customer — context lives in
workspace/<project>/; agents and workflows scope to that project; data is isolated byproject_id(formerlyorg_id) in the DB. - Shared content, per-tenant overrides — operations / workflows that ship with the framework are global; tenant context can override slug-keyed.
For the support-reply demo, each customer would get their own support-demo project with their own ticket history. The workflow definition stays shared.
4. Observability
Every operation run + workflow step emits a Langfuse trace. The standard tags:
feature:support-reply(per-feature analytics)org:<projectId>(per-tenant cost breakdown)slug:summarize_ticket(per-operation latency / cost)userId,sessionId(per-user attribution)
libs/Langfuse.ts exposes traceFor({ feature, slug, orgId, userId }). Use it from any new operation or action handler so traces stay sliceable. The Langfuse UI at http://localhost:3200 (or your hosted instance) is where you debug latency outliers, model cost surprises, and prompt regressions.
5. Approval queue at scale
The default /dashboard/review queue scales fine for a few-drafts-per-day operator; it doesn't scale to a 24/7 contact center. For higher-volume teams:
- Add a webhook on draft-completed → notify your tools (Slack, on-call rota)
- Build an SLA dashboard from
skill_run.created_at+skill_run.approved_atdeltas - Wire AI-assisted batch approval (only the cards that fail confidence checks need eyes)
These hooks already exist in v0.4.0; the dashboards and webhooks ship as separate plugins.
What to change for a different L1 demo
The support-reply template generalizes. Any "input → AI step → AI step → human approval → side effect" workflow follows the same shape. Examples:
- Sales follow-up — CRM lead + activity notes → drafted email → approve → Gmail send
- Weekly report — metrics CSV → drafted summary → approve → Slack post + S3 archive
- Incident triage — alert payload → classified severity → drafted Slack message → approve → page rota
For each: write two YAML files (skill.yaml + prompt.md) per operation, one workflow.yaml, one or two custom Cards. The runtime handles the rest.
Reference
- Demo source: https://github.com/vocion/vocion-demos/tree/main/demos/support-reply
- Blog tutorial: /blog/build-support-reply-system
- Cards SDK:
vocion-core/packages/sdk/src/cards.ts - Workflow schema:
vocion-core/packages/core/src/libs/workspace/schemas.ts→WorkflowManifestSchema - Operation schema:
vocion-core/packages/sdk/src/contract.ts→Operation<Input, Output> - Observability tags: /docs/guides/observability
- Cost caps: /docs/features/agents (search
agent_budget)