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.
- The help-desk transmission: webhook in, reply out, through your provider SDK
- Domain tools over your systems: orders, refunds, accounts
- The refund form and the rules for when a reply needs approval
- An account-session adapter that resolves credentials by reference
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.
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.
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:
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:
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:
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
classifyInboundruns 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.
requiresPermissionbecomes 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
- Deploy the Foundry folder as a unit:
kuiper units create support, thenkuiper link -u support --repo … --branch mainso every push deploys. - Add a
requestAuthorizationadapter that accepts a bearer token, store it withkuiper secrets set FOUNDRY_TOKEN=-and bind it with'FOUNDRY_TOKEN=${{secrets.FOUNDRY_TOKEN}}'. Your webhook route and console send it. - 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}}'. - Keep runtime data, transcripts and guidance state under
/data. For several hosts, implementFoundryDataAdapteron a managed Postgres. - 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
- Foundry tutorial: a customer-support agent end to end, with an approval and an OpenAI SDK client.
- examples/classifier-inbox: a labelled inbox triaged with classifiers, measured against an LLM.
- Build the parts one at a time: make it remember, make it a system.
- Reference: Apps & transmissions, Playbooks, schedules & sleep, Goals, facts & forms, Goal-oriented agents.