Make it a system
The release assistant is a complete harness, but it is still one agent in one process. Every team wants their own. It should check releases every Monday without being asked. A Slack message should reach it. Publishing should wait for a person's approval even when nobody is watching the chat, and the rest of your stack should be able to call it. That is the line between a harness and a system, and it is what Glove Foundry is for.
Move the harness into Foundry
npx glove-foundry init release-desk --template minimal
cd release-desk && pnpm install && pnpm dev # inspector and WebChat at http://localhost:4141A Foundry project is a folder of agent definitions. The file path is the agent's id, so agents/release/agent.ts is the agent release. Everything from steps 1 to 6 moves into its fields:
import { createAdapter } from "glove-core/models/providers";
import { defineAgent, defineSchedule } from "glove-foundry";
import { FileStore } from "../../lib/file-store.js";
import { listMergedChanges, tagRelease } from "./tools.js"; // server-side versions of the step 1 and 2 tools
const mondayCheck = defineSchedule({
name: "monday-release-check",
timing: { kind: "cron", expression: "0 9 * * 1", timezone: "UTC" },
message: "Check what merged last week and say whether a release is due.",
});
export default defineAgent({
description: "Drafts, reviews and ships releases for one team",
// Every field is a value or a function of the run's context.
model: () => createAdapter({ provider: "anthropic", model: "claude-sonnet-4-20250514" }),
systemPrompt: (_agent, ctx) =>
`You help the ${String(ctx.agentInstance.context.team)} team ship releases. Use tools for facts.`,
// One durable store per conversation (step 4): each run is a fresh process.
store: ({ conversationId }) => FileStore.open(conversationId),
// Always-on tools. On the server, tag_release drops its pushAndWait confirm and
// calls your release API directly with requiresPermission: true, which Foundry
// turns into an approval record (below).
tools: [listMergedChanges, tagRelease],
// Desired recurring work, per instance; a team can opt out in its context.
schedules: (_agent, ctx) => (ctx.agentInstance.context.weekly === false ? [] : [mondayCheck]),
compactionInstructions: () => "Keep what is merged, what is decided and what is blocked.",
});The tools move to the server: tag_release calls your release API directly instead of fetch("/api/releases"), and its browser confirm becomes requiresPermission: true. Context providers, skills, memory, the working environment, a REPL and subagents have fields of their own (contextProviders, skills, memory, workingEnvironment, repl, subagents) or mount in configure(agent, ctx). See defineAgent & assembly.
Identity: one definition, an instance per team
import { createFoundryClient } from "glove-foundry/client";
import type { FoundryRoutes } from "../.foundry/routes.js";
const foundry = createFoundryClient<FoundryRoutes>({ baseUrl: "http://127.0.0.1:4141" });
const payments = await foundry.agent("release").create({ context: { team: "payments" } });
const thread = await foundry.createConversation(payments.id, { title: "2.4.0" });
const run = await foundry.send(payments.id, thread.id, "Draft notes for 2.4.0 and publish when ready.");
const done = await run.wait({ timeoutMs: 120_000 });An instance is data: its id, its context, the capabilities installed on it, its playbooks. Each has any number of conversations. Every run reloads the instance and assembles a fresh harness from the definition, the instance and the message, in its own process. That is also why the store in step 4 had to be durable: a MemoryStore would start every message empty.
Time: schedules, sleep, spawn
The Monday schedule above is reconciled into a durable activation for every instance and rebuilt on restart. Agents also get tools to make their own: glove_foundry_schedule ("remind me Thursday"), glove_foundry_sleep ("check CI again in 20 minutes": the run suspends and the same conversation wakes later), glove_foundry_schedules to list, pause or cancel them, and glove_foundry_spawn to start another instance's run. No job queue, no cron.
Oversight: approvals that wait for a person
In step 2, requiresPermission showed a prompt in the browser. In Foundry the same flag pauses the run on an approval record with the exact tool input, which expires after five minutes by default and fails closed. Anyone you let in resolves it: in the inspector, over HTTP, or from code:
const [pending] = await foundry.approvals({ runId: run.id, status: "pending" });
if (pending) await foundry.resolveApproval(pending.id, "approve"); // or "deny"If someone changes their mind mid-run, run.steer("Hold 2.4.0, include #421 first") interrupts the run cleanly and restarts it in the same conversation with the new instruction.
Arrival: Slack, email, webhooks
Inbound channels are transmissions: typed definitions of how events arrive (verify, normalise, classify) and how replies go out (an outbound transmission becomes a validated tool once installed). An application bundles them so an instance can install "Slack". Playbooks route each event to the right instance and conversation, and a playbook subscription can create the instance on the first message. Delivery is idempotent per event. Credentials stay in adapters you own. The full walkthrough is apps & transmissions; on Kuiper, email arrives as a signed webhook to your unit.
Layers: a fast agent in front, workers behind
The reviewer from step 6 ran inside the release assistant's turn. Work that takes minutes should not: list sibling agents in delegates and the assistant hands it off with glove_foundry_delegate, keeps talking, and hears back in the same conversation. A worker that needs something asks with glove_foundry_ask. Decide the layers by how fast each part must reply, what it must remember and how long it runs; see Layered agents.
Access: the APIs you didn't write
- Typed client, above: route names are checked against your agent files.
- OpenAI-compatible
/v1/chat/completionsand/v1/responses: point any OpenAI SDK or chat UI at an instance; conversations stay durable. - Run API
/v1/runswith live events, and REST over every record at/api/*. - Inspector and WebChat at
/: every instance, conversation, run, model call, tool call and approval.
Already built for you
| Primitive | What you get | Instead of building |
|---|---|---|
| Definitionsglove-foundry | The file path is the id. Every field can be a function of the instance, conversation and incoming message.agents/<route>/agent.ts → export default defineAgent({ … }) | a factory that rebuilds the agent per request from a switch over tenants |
| Instancesglove-foundry | Persisted agents with their own id, context, workspace, installations and playbooks.client.agent(route).create({ context }) · defineAgentInstance(definition, { … }) | an agents-per-customer table and lookup |
| Conversationsglove-foundry | Any number of durable threads per instance with exact transcripts.client.createConversation(agentId) · client.conversationTranscript(…) | thread tables and routing messages to the right history |
| Installable capabilitiesglove-foundry | Capabilities mount only on instances that install them, with typed config.defineSharedTool · defineApp · defineMcp · install(definition, config) | per-customer feature flags around tool lists |
| Shared workglove-foundry | Workspace entries, tasks, a shared inbox and scoped values that agents coordinate through.glove_foundry_workspace · _tasks · _shared_inbox · _environment | coordination tables between agents |
| Data adaptersglove-foundry | Instances, conversations, activations and workspace data persisted atomically; swap in a database for several hosts.new FileFoundryDataAdapter({ file }) · FoundryDataAdapter | serialising runtime state yourself |
| Schedulesglove-foundry | Cron, interval and one-shot work, declared in code or created by the agent, rebuilt on restart.defineSchedule({ timing: { kind: "cron", expression }, message }) · glove_foundry_schedule | cron jobs and a job queue that call your agent |
| Sleepglove-foundry | Suspend the run and wake the same instance and conversation later.glove_foundry_sleep({ kind: "for", duration: "20m", message }) | storing 'check back later' state and a timer |
| Spawn and backgroundglove-foundry | Start another instance's run, or continue work without blocking the reply.glove_foundry_spawn · glove_foundry_background | fire-and-forget promises that die with the request |
| Schedule managementglove-foundry | The agent lists and edits its own reminders and recurring work.glove_foundry_schedules({ action: "list" | "update" | "pause" | "resume" | "cancel" }) | a reminders CRUD API for the agent |
| Transmissionsglove-foundry | Typed inbound events and outbound delivery for one channel; outbound becomes a validated tool when installed. Credentials stay in your adapter.defineTransmission({ inbound, outbound, account }) | a webhook handler and an API client per channel |
| Applications and connectionsglove-foundry | Channels bundled into something an instance installs; long-lived listeners started only while something needs them.defineApp({ inbound, outbound, connections }) · defineConnection(…) | polling loops and socket clients you keep alive yourself |
| Playbooksglove-foundry | Rules that match events to instances, with idempotent delivery into the right conversation.composePlaybook({ transmission, match, directives }) | a dispatcher that decides which agent handles which event |
| Subscriptionsglove-foundry | An event can create the agent that handles it: zero, one or many instances provisioned atomically.definePlaybookSubscription(…) · runtime.putPlaybookSubscription(…) | onboarding code that creates an agent before its first message |
| Inbound triageglove-classifier/foundry | Classify events before any agent starts, and match playbooks on the answers.classifyInbound({ classifier, question }) · classifierPredicate(…) | waking an LLM agent to read every event |
| Approvalsglove-foundry | Gated tool calls pause the run on an expiring, fail-closed approval record with the exact input.requiresPermission → client.resolveApproval(id, "approve") | an approvals table and a pause/resume flow |
| Steeringglove-foundry | Interrupt running work cleanly and restart it in the same conversation with the new instruction.handle.steer(message) · POST /v1/runs/:id/steer | cancel-and-resend logic that races the old run |
| Inspector and WebChatglove-foundry | Every instance, conversation, run, model call, tool call and approval, plus a chat to talk to any instance.glove foundry dev → http://localhost:4141 | an admin dashboard and a debug chat |
| Event streamglove-foundry | Correlated events by definition, instance, conversation and run, live or after the fact.GET /api/events (SSE) · defineSubscriber(…) | log scraping to reconstruct what an agent did |
| Typed clientglove-foundry/client | Create instances, send messages, and wait on, stream, cancel or steer run handles, with route types generated from your files.createFoundryClient<FoundryRoutes>({ baseUrl }).agent(route) | a hand-written HTTP client for your agent service |
| OpenAI-compatible APIglove-foundry | Any OpenAI SDK or chat UI talks to a durable instance and conversation.POST /v1/chat/completions · POST /v1/responses | an adapter so existing chat clients can reach your agent |
| Run API and RESTglove-foundry | Asynchronous runs with live events, and REST over every runtime record.POST /v1/runs · GET /v1/runs/:id/events · /api/* | a job-status API around your agent |
| Request authorizationglove-foundry | One check in front of the API, the inspector and the event streams. Foundry refuses a public bind without it.defineApplication({ requestAuthorization: { authorize } }) | auth middleware in front of each route |
Next
The release desk works on your machine, with its state in .data/. In step 8 it goes to production on Kuiper: the same folder, no Dockerfile, state on a persistent disk, models through a gateway, and its runs in the dashboard.