Harness blueprint

Support desk

A support harness answers customers where they already are: a ticket queue, a chat widget, an email thread. It is defined by the system layer. Each customer gets their own agent with their own installed integrations (identity), tickets wake it (arrival), cheap classifiers decide what deserves a model at all, and anything that moves money waits for a person (oversight). The agent loop in the middle is ordinary Glove.

This harness uses 29 primitives across 13 parts. Everything listed is already built; the dashed box is what you write.

How it fits together

A customer opens ticket T-1001, “order 4417 never arrived”. The help desk posts a webhook; your route verifies the signature and calls dispatchInbound with the provider's event id and the ticket id as the thread key. Foundry claims routeId:eventId before anything else, so a provider retry never starts a second run.

The transmission authenticates and normalises the payload, then its classify step asks a classifier model which kind of event this is: refund, bug or other, in milliseconds and without an LLM reading the customer's data. A playbook subscription matches refund events and finds the customer's instance, or creates one, with the help-desk app installed against their account. The event becomes a turn in the ticket's conversation and a run starts.

The run looks the order up, records what the customer already said as facts, and walks the refund form so it only asks for what is missing. When it calls issue_refund, the permission gate pauses the run on an approval record. A person approves it in the inspector or from your own queue; the refund goes through and the agent replies on the ticket through the transmission's outbound tool. Every step is a correlated event you can open later.

Outside worldSlack · email · webhooksTransmissionverify · normalisePlaybookmatch · provisionSchedulescron · interval · sleepInstancedefinition + dataClienttyped · REST · OpenAIConversationdurable threadRuna fresh Glove per runReplyoutbound toolApprovalpause · approve · denyInspectorevery run and calleventtypedroutes · createsactivationmessagehistoryassemblesrepliesasksemits
One ticket through a support desk: the webhook becomes a typed event, the classifier picks the event kind, the playbook finds or provisions the customer's instance, and the run replies on the ticket, pausing on an approval before money moves.

Skeleton

The transmission, app, routes and webhook route are written out in full in Apps & transmissions, which builds the same help desk. These are the pieces that make it a support desk.

agents/support/agent.tstypescript
import { getToolJsonSchema } from "glove-core";
import { createAdapter } from "glove-core/models/providers";
import { MemorySchema } from "glove-memory/core";
import { createSqliteMemoryAdapters } from "glove-memory/sqlite";
import {
  defineAgent,
  defineFacts,
  defineForms,
  defineSubagent,
  foundryGuidanceSubject,
  type AgentAssemblyContext,
} from "glove-foundry";
import { z } from "zod";
import { FileStore } from "../../lib/file-store.js";
import { components } from "./composition.js"; // composeAgent(helpdesk, …)
import { refundForms } from "./refund-form.js"; // a FormRegistry with the refund form
import { orders } from "../../lib/orders.js"; // your code: your order system

const guidance = (ctx: AgentAssemblyContext) =>
  createSqliteMemoryAdapters({
    file: "/data/guidance.sqlite",
    namespace: foundryGuidanceSubject(ctx, "conversation"),
    schema: new MemorySchema(),
  });

export default defineAgent({
  description: "Answers one customer's tickets",
  components,
  model: () => createAdapter({ provider: "anthropic", model: "claude-sonnet-5-5" }),
  store: ({ conversationId }) => FileStore.open(conversationId),
  systemPrompt: (_agent, ctx) =>
    `You support ${String(ctx.agentInstance.context.customer)}. Reply on the ticket. Never promise a refund before it is issued.`,
  contextProviders: (_agent, ctx) => [
    // Re-read before every model call, so a plan change mid-ticket is seen at once.
    async () => `Account: ${JSON.stringify(await orders.account(String(ctx.agentInstance.context.customer)))}`,
  ],
  tools: [
    {
      name: "lookup_order",
      description: "Get an order's status, items and shipping events.",
      inputSchema: z.object({ orderId: z.string() }),
      async do({ orderId }) {
        return { status: "success" as const, data: await orders.get(orderId) };
      },
    },
    {
      name: "issue_refund",
      description: "Refund an order, fully or in part.",
      inputSchema: z.object({ orderId: z.string(), amount: z.number().positive() }),
      requiresPermission: true, // becomes an expiring approval record
      async do(input) {
        return { status: "success" as const, data: await orders.refund(input) };
      },
    },
  ],
  facts: (_agent, ctx) => defineFacts({ adapter: guidance(ctx).facts }),
  forms: (_agent, ctx) => defineForms({ adapter: guidance(ctx).forms, registry: refundForms }),
  subagents: [
    defineSubagent({
      name: "order-researcher",
      description: "Read-only: gather what happened to one order.",
      systemPrompt: "Gather facts about one order. Never change anything. Return a short summary.",
      tools: ({ parent }) => parent.tools.filter((tool) => tool.name === "lookup_order").map((tool) => ({
        name: tool.name,
        description: tool.description,
        jsonSchema: getToolJsonSchema(tool),
        do: (input, _display, _glove, signal) => tool.run(input, undefined, signal),
      })),
    }),
  ],
});

The subagent re-exposes only the parent's read tool, so it can research an order but never refund one. Triage happens before any of this runs, in the transmission's inbound contract:

agents/support/transmissions/tickets.transmission.ts (inbound)typescript
import { choice, jev } from "glove-classifier";
import { classifyInbound } from "glove-classifier/foundry";

// inside defineTransmission({ inbound: { … } })
classify: classifyInbound({
  classifier: jev(), // TypeSafe's Jev; any ClassifierAdapter works
  question: choice("What is this ticket about?", ["refund", "bug", "other"]),
  events: { refund: refundRequested, bug: bugReported },
  fallback: generalInquiry, // below minConfidence, or "other"
  minConfidence: 0.6,
  state: (event: { subject: string; body: string }) => `${event.subject}\n${event.body}`,
}),

And a subscription gives every customer their own agent the first time they write in, with the help desk installed against their account:

agents/support/subscriptions/refunds.subscription.tstypescript
import { definePlaybookSubscription, install } from "glove-foundry";
import support from "../agent.js";
import helpdesk from "../apps/helpdesk.app.js";
import handle from "../actions/handle.action.js";
import refundRequested from "../events/refund-requested.event.js";
import tickets from "../transmissions/tickets.transmission.js";
import { acmeHelpdesk, ticketsInbound, ticketsOutbound } from "../topology.js";

export default definePlaybookSubscription({
  workspaceId: "acme",
  playbook: {
    transmission: tickets,
    match: { event: refundRequested, routes: [ticketsInbound] },
    directives: [{ action: handle, instruction: "Resolve the refund request on this ticket." }],
    applications: [helpdesk],
    outbound: [{ route: ticketsOutbound, application: helpdesk }],
  },
  targets: [{
    agent: support,
    // "per-requester" is your provisioner in foundry.application.ts:
    // it returns a provisioningKey per customer, so each customer gets one instance.
    provisioning: { mode: "custom", adapter: "per-requester" },
    installations: [install(helpdesk, { queue: "support" }, { account: acmeHelpdesk })],
  }],
});

Resolve approvals from anything that can call the API. Your support console, for example, can list what is waiting:

console/approvals.tstypescript
import { createFoundryClient } from "glove-foundry/client";

const foundry = createFoundryClient({
  baseUrl: process.env.FOUNDRY_URL,
  // The same bearer token your requestAuthorization adapter accepts.
  authorization: {
    identifier: "support-console",
    headers: () => ({ authorization: `Bearer ${process.env.FOUNDRY_TOKEN}` }),
  },
});
for (const approval of await foundry.approvals({ status: "pending" })) {
  // show approval.toolName and approval.toolInput to a person, then:
  await foundry.resolveApproval(approval.id, "approve"); // or "deny"
}

What not to build

  • A webhook handler and a dispatcher per channel. Transmissions normalise events and playbooks route them, with idempotent delivery keyed on the provider's event id.
  • Onboarding code that creates an agent before a customer's first ticket. Subscriptions provision the instance when the event arrives, atomically.
  • An LLM call to sort every ticket. Classifiers answer typed questions in milliseconds, and classifyInbound runs before any agent starts.
  • A slot-filling state machine for refunds. Forms validate each answer, allow skips and undo, and run executors when complete; facts keep what the customer said before you asked.
  • An approvals table and a pause/resume flow. requiresPermission becomes an expiring, fail-closed approval record with the exact input.
  • Tool lists behind per-customer feature flags. Installable capabilities mount only on the instances that install them, with typed config.

Deploy on Kuiper

  1. Deploy the Foundry folder as a unit: kuiper units create support, then kuiper link -u support --repo … --branch main so every push deploys.
  2. Add a requestAuthorization adapter that accepts a bearer token, store it with kuiper secrets set FOUNDRY_TOKEN=- and bind it with 'FOUNDRY_TOKEN=${{secrets.FOUNDRY_TOKEN}}'. Your webhook route and console send it.
  3. If the webhook receiver is a separate unit (a Next.js app, say), point it at Foundry over the private network: 'FOUNDRY_URL=${{support.INTERNAL_URL}}'.
  4. Keep runtime data, transcripts and guidance state under /data. For several hosts, implement FoundryDataAdapter on a managed Postgres.
  5. Route models through the gateway so every call is recorded with its tokens and cost (kuiper models calls --unit support).

The unit's Inspect card and the project's Agents page show runs, conversations and pending approvals. Full walkthrough: Deploy on Kuiper.

Go further