docs/guides/building-a-support-reply-system.md

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 runtimesupport_triage chains two operations + an approval gate + a send action, with durable state in workflow_run rows
  • Operationssummarize_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 send action's envelope is rendered via the first-party send-stub Card; 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:

  1. 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.
  2. Low-temp triage, higher-temp drafting. summarize_ticket runs at 0.2 for stable classification; draft_reply at 0.4 for natural prose. Don't crank the temperature uniformly.
  3. requiresApproval: true for 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 a skill step 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 handlervocion-core/packages/core/src/services/temporal/activities/index.ts has executeActionActivity (v1 stub). Replace with an action-handler-registry pattern: each action: <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: true to false in the envelope; the send-stub Card already changes the badge from "Stub" to "Sent." Or ship a tenant-specific card with the Sent state 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 by project_id (formerly org_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_at deltas
  • 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