← Blog

Glove from A to Z: Building agents from scratch

Start with a loop that calls a function. Add one thing at a time, only when something breaks. By the end you have an agent that runs code, remembers, answers the phone with a face on it, and ships in a container — and you understand every piece, because you watched each one earn its place.

The docs answer “how do I do X in Glove”. This is the other question: why is any of it there. So we build an agent from nothing, and at each step we do the smallest thing that works, run into the wall that makes it stop working, and reach for the next primitive. The order below is roughly the order the framework itself grew in.

Everything here is real API. If you want to follow along, the quickstart gets you a project in fifteen minutes.

A. The loop is the whole idea

An agent is not a model. An agent is a loop around a model, and it is small enough to write on a napkin:

the agent loop, in fulltypescript
let messages = [userMessage];

while (true) {
  const reply = await model.prompt({ messages, tools });
  messages.push(reply);

  if (reply.toolCalls.length === 0) return reply.text;  // it is done talking

  for (const call of reply.toolCalls) {
    const result = await tools[call.name].run(call.input);
    messages.push({ role: "tool", call_id: call.id, result });
  }
}

That is it. The model reads a list of tools, picks one, you run it, you hand the result back, it decides again. Nothing in there is clever. The interesting part is that this loop replaces something we have been writing by hand for twenty years: routing. Pages, navigation hierarchies, wizard steps, “if the cart is empty show the empty state” — that is control flow encoded in UI, and the loop above does it from a sentence instead.

So if the loop is this simple, what is a framework for? Everything that happens the moment you run this in production: the conversation outgrowing the context window, the tool that needs to ask the user a question mid-run, the fifty tools you cannot afford to send on every turn, the second agent, the container it all ships in. Each section below is one of those.

B. The smallest agent that does something

Here is the loop above, in Glove, with one capability attached. No React, no server, no database — a script you can run with tsx.

agent.tstypescript
import { Glove, Displaymanager, createAdapter } from "glove-core";
import { z } from "zod";

const agent = new Glove({
  model: createAdapter({ provider: "anthropic" }),
  displayManager: new Displaymanager(),
  systemPrompt: "You help people track parcels.",
  serverMode: true,
  compaction_config: {
    compaction_instructions: "Summarise the conversation so far.",
  },
})
  .fold({
    name: "track_parcel",
    description: "Look up the current status of a parcel by its tracking number.",
    inputSchema: z.object({
      tracking: z.string().describe("The carrier tracking number"),
    }),
    async do(input) {
      const status = await carrier.track(input.tracking);
      return { status: "success", data: status };
    },
  })
  .build();

const result = await agent.processRequest("where is 1Z999AA10123456784?");
console.log(result.messages.at(-1)?.text);

Four things are worth naming, because they recur everywhere below.

fold adds a capability and returns the builder, so tools chain. It is also legal after build() — that is not a curiosity, it is how tools get added mid-conversation later on.

The description is the interface. Not the function name, not the types — the sentence. The model chooses tools by reading it, so a vague description is a bug, and every .describe() on a schema field is documentation the model actually consumes.

No store was passed. Glove built one — a MemoryStore, process-local, gone on restart. Fine for a script; section D is where that stops being fine.

serverMode: true is the canonical “I am headless” flag. It tells the parts of the framework that would otherwise try to ask a human something not to.

C. Tools, and the two audiences of a result

A tool returns a result, and that result has two readers who want different things. The model wants a compact description it can reason about. The screen wants everything needed to draw a card. Sending both to both is how a context window fills with base64.

So a result is split:

a tool resulttypescript
return {
  status: "success",
  data: `Delivered 2026-08-05 to the front desk.`,  // → the model
  renderData: { events, signature, mapTile },        // → your renderers only
};

Model adapters strip renderData before the request goes out. That makes it the right place for anything the model has no business seeing — image bytes, internal ids, a customer's email address — and it is what renderResult reads when redrawing the conversation from history.

The same split has a second, quieter form. Long results poison later turns: a file you read on turn two is still costing tokens on turn twenty. Turn on enableToolResultSummary, give a tool a generateToolSummary, and the full payload is swapped for a one-line summary once the turn is over — current results stay whole, older ones shrink to “Read invoice.pdf, 4 pages”. The store keeps both; only what goes to the model is rewritten.

D. Where the UI went

The loop returns text. Real apps need to show a product grid, take a card number, get a yes or no. The conventional answer is to route to a page. Glove's answer is that a tool pushes UI onto a stack, and can block on it.

lib/tools.tsxtsx
const confirmDelivery = defineTool({
  name: "confirm_redelivery",
  description: "Ask the user to confirm a redelivery date before booking it.",
  inputSchema: z.object({ date: z.string(), address: z.string() }),
  displayPropsSchema: z.object({ date: z.string(), address: z.string() }),
  resolveSchema: z.boolean(),
  displayStrategy: "hide-on-complete",

  async do(input, display) {
    const ok = await display.pushAndWait(input);   // ← the tool pauses here
    if (!ok) return { status: "success", data: "User declined the date." };
    await carrier.book(input);
    return { status: "success", data: "Redelivery booked.", renderData: input };
  },

  render({ props, resolve }) {
    return (
      <Card>
        <p>Redeliver to {props.address} on {props.date}?</p>
        <button onClick={() => resolve(true)}>Yes</button>
        <button onClick={() => resolve(false)}>Pick another day</button>
      </Card>
    );
  },
});

pushAndWait suspends the tool — and with it the agent loop — until something calls resolve. pushAndForget renders and carries on. That one distinction covers the whole space between “here are your results” and “I need an answer before I can continue”, and it is why a Glove app tends not to have routes: the agent decides what is on screen, in the order the conversation needs it.

The display manager is an adapter, so the same tool works wherever you can draw. In React, useGlove() and <Render> handle it. On a server the slot goes over a WebSocket and dm.resolve(slotId, value) comes back from whatever the client is — a terminal, a Slack message, a phone.

Voice changes this rule. A tool that blocks on a click is unusable when the user is driving. In voice-first apps prefer pushAndForget and put the answer in data, where the model can say it out loud.

E. State, and the four things you get for free

The conversation has to live somewhere. That somewhere is a StoreAdapter: messages in, messages out, plus token and turn counters. Implement it over Postgres, Redis, a file, whatever you already run.

The interesting part is what is optional on that interface. Implement more methods and features switch themselves on:

ImplementYou get
getTasks / addTasks / updateTaskA glove_update_tasks tool — the agent keeps its own to-do list across a long job
getPermission / setPermissionConsent gating for tools marked requiresPermission
The four inbox methodsglove_post_to_inbox, and everything built on it
createSubAgentStoreSubagents with their own isolated (or durable) history

Skip them and they are silently disabled. Nothing to configure, nothing to turn off.

The inbox deserves a paragraph of its own, because it is the least obvious primitive in the framework and the most reused. It is a mailbox for things that cannot be answered now. The agent posts “tell me when this parcel actually moves”; the conversation ends; three hours later a webhook resolves the item; on the next ask() the answer is injected as context and the agent picks up where it left off. Nothing polled, nothing held open. Two packages further down this page are built on that one mechanism.

F. The conversation outgrows the window

Every agent hits this. The fix is compaction: past a threshold, the conversation so far is summarised into one message, and the model is shown the summary plus everything after it.

The detail that matters is what the store keeps. Full history — always. Compaction splits what the model sees; it does not delete what happened. Your transcript UI still renders the whole thing, your analytics still see every turn, and a summary message is just a message flagged is_compaction: true. Pending inbox items survive the summary too, because “I am still waiting on something” is exactly the fact a summariser would otherwise drop.

Between tool-result summaries and compaction, context stops being something you think about until you are doing something genuinely large — at which point section J is the real answer.

G. Saying no

An agent that can book a redelivery can book forty. Mark a tool requiresPermission and the executor checks the store before it runs.

Two details make this usable rather than annoying. Permission is keyed on tool and input, not tool alone — approving rm build/ does not approve rm /, and repeating the identical call reuses the decision. And the gate itself can be a function of the input:

one tool, two risk levelstypescript
{
  name: "bash",
  description: "Run a shell command in the project directory.",
  inputSchema: z.object({ cmd: z.string() }),
  requiresPermission: (input) => !/^(ls|cat|grep|git status)\b/.test(input.cmd),
  async do(input) { /* … */ },
}

Reads run free; anything else asks. Your store decides how to answer — exact match, a regex allowlist, a prompt on screen, a policy engine. The framework only asks the question.

H. Shaping the agent from outside the loop

Three extension points, and the difference between them is when they fire.

Hooks run before the model does, on a /token in the user's message. They get the real controls: force a compaction, swap the model mid-conversation, rewrite the message, or short-circuit the turn entirely so the model is never called. /stop, /compact, /model haiku — all four lines of code.

Skills inject context. /concise materialises a synthetic user message ahead of the real one, flagged is_skill_injection: true so your transcript can render it differently. Mark one exposeToAgent: true and the agent can pull it in itself when it decides it needs that context.

Subagents are a whole second Glove the main one can hand a task to. This is the one people reach for too late. A subagent gets its own store, its own tool list, and — importantly — none of the parent's context: the only thing that crosses is the prompt string. That isolation is the point. A research subagent can burn 60k tokens reading twelve pages and hand back a paragraph, and the parent conversation never carries the twelve pages.

agent.tstypescript
agent.defineSubAgent({
  name: "claims",
  description: "Handles damaged-parcel claims end to end. Use for anything about damage.",
  factory: async ({ parentStore, parentControls }) => {
    const store = await parentStore.createSubAgentStore?.("claims", false);
    return new Glove({
      store,
      model: parentControls.glove.model,               // inherit the parent's model
      displayManager: parentControls.displayManager,   // and its screen
      systemPrompt: "You process damage claims. Answer the prompt and return.",
      compaction_config: { compaction_instructions: "Summarise claim progress." },
    })
      .fold(lookupPolicyTool)
      .fold(fileClaimTool)
      .build();
  },
});

Note what the user types: @claims my box arrived crushed. Glove does not parse that. The @ reaches the model verbatim and acts as a routing hint; the model decides whether to call the dispatch tool. Invocation is therefore not guaranteed — but two mentions in one sentence work with no extra machinery, which is the trade the convention is making.

I. Tools you did not write

At some point the capability you need already exists behind someone else's API — Notion, Linear, Gmail, your own internal service. glove-mcp bridges Model Context Protocol servers so their tools appear as ordinary Glove tools, notion__search and friends.

agent.tstypescript
await mountMcp(glove, {
  adapter,     // per-conversation: which servers are active, and how to get a token
  entries,     // the static catalogue your app supports
});

The split is deliberate. The catalogue is application code — identical for every user. The adapter is per-conversation state: which servers this user has connected, and how to resolve a token for them. Credential storage and refresh stay yours, because they were always going to be.

One contract to know: an expired token surfaces as a normal tool result, { status: "error", message: "auth_expired" }. Not a thrown exception, not a silent retry. Your app watches for it, refreshes, and the next call picks up the new token. That is a “Reconnect Notion” toast in about ten lines.

mountMcp also registers a discovery subagent, so the agent can go find and activate a server mid-conversation instead of you wiring the whole catalogue at boot — which is exactly what the “fold after build” note in section B was for.

J. Stop calling tools. Run code.

Now the real problem, and the one with the most interesting answer. Tool definitions are re-sent on every single model call. And results come back to the model: ask “how many of these forty PRs are stale” and the whole list pages through the context window so the model can count them by eye.

Both costs grow with your integrations, and we measured where that ends. A production-shaped fleet — 40 servers, 367 tools, of which any given task needs at most four — costs a conventional agent ~39k tokens of standing tool schemas before the conversation has said anything. At that scale the conventional arm does not just get expensive, it inverts: it becomes the least accurate arm in the suite, at 12× the peak context and roughly 6× the cost of a single code-execution tool. Folding tools is fine at six and actively harmful at three hundred.

So: stop giving the model a menu. Give it a runtime, and one tool to run code in it. Your capabilities become functions — a shared ToolFn catalogue that mounts on any of the surfaces below unchanged, and which an MCP connection can populate in one line.

The JavaScript REPL

agent.tstypescript
import { JsSession, mountJs } from "glove-js";
import { fnsFromMcp } from "glove-scratchpad/fns/mcp";

const session = JsSession.create();
session.registerAll(await fnsFromMcp(githubConn));  // github__list_pull_requests, …

mountJs(agent, { session });   // one tool: execute_js

Now the model works by writing programs:

what the model writesjavascript
const prs = github.list_pull_requests({ state: "open" });
const stale = prs.filter(p => p.age_days > 30);

stale.length === 0
  ? "all fresh"
  : `${stale.length} stale: ${stale.map(p => p.number).join(", ")}`;

One call. Forty rows were fetched, filtered and counted — and the only thing that crossed back into the context window is a sentence. That is the whole argument, and it has four consequences worth naming.

Data flow goes off-context. const prs = … parks the rows in the REPL and echoes a summary. The model then works with prs.length and prs.slice(0, 5) rather than with the corpus. Values past a bound are structurally elided with a marker naming the true size, so a careless console.log cannot blow up the turn.

Decide-and-act is one call. if (incidents.length === 0) slack.post(…) else email.send(…) — a read, a decision and an effect, without the model round-tripping to look at the read first. On a benchmark scenario graded on which side effect actually fired, this was the only surface where every model passed, and three of them did the whole thing in a single tool call.

Effects are exactly-once by construction. A function fires when its expression evaluates. There is no planner that might re-run it, which is a much stronger guarantee than a prompt asking nicely.

Discovery is progressive and in-band. Nothing is primed by default. The model calls search("open pull requests") to jump straight to matching functions, or browses servers()fns("github") describe(name). The same tiers also exist as native tools, and those names work inside the code as aliases — so a model primed on the tool names lands its call whichever way it reaches.

The Python REPL

The identical catalogue, a different language. Tool calls take keyword arguments; comprehensions, f-strings, slicing and def are all in the subset.

the same job, in execute_pythonpython
prs = github.list_pull_requests(state="open")
stale = [p for p in prs if p["age_days"] > 30]

"all fresh" if not stale else f"{len(stale)} stale: " + ", ".join(str(p["number"]) for p in stale)
agent.tstypescript
const session = PySession.create();
session.registerAll(await fnsFromMcp(githubConn));

mountPy(agent, { session });   // one tool: execute_python

Pick the language your models are most fluent in — that is the entire selection criterion, and it is measurable. Hardening the JS surface against real transcripts moved it from 78% to 97% on the benchmark; Python, built with that tuning already baked in, landed parity-class from day one. Fluency is not a property you hope for, it is a knob you turn — and the turning is unglamorous: every place a surface silently deviated from what the model expected was a place a weak model failed, and every fix that made truth cheaper to see bought more capability than any prompt instruction.

The sandbox is structural. A program is parsed, whitelist-validated and only then run — by a tree-walking evaluator with a fuel budget (so while (true) {} cannot hang), a recursion cap, and an abort signal. Every property read and method call goes through a boundary that blocks constructor, __proto__, call/apply/bind. No import, no eval, no fetch. A program cannot climb back to the host.

The same capabilities as a database

glove-scratchpad is where all of this started, and it is still the right surface for a particular shape of work. Same idea — one execute_sql tool instead of a catalogue — but capabilities are modelled as tablesrather than functions: resources with columns, CRUD verbs wired independently, and WHERE equalities pushed down as arguments.

what the model writessql
-- composition across two services, executed inside the engine
INSERT INTO linear_issue (title, body)
SELECT title, 'Follow-up for ' || url FROM github_pr WHERE merged = true;

Reach for it when the work is aggregation and composition — grouping, joining, piping one service into another — or when you want the things a database solved decades ago: discovery through information_schema, a genuine dry run via EXPLAIN (it calls no resolvers at all), and staged writes inside BEGIN … COMMIT, where each effect is recorded with its exact arguments and fired only on commit. That staging surface is the best approval gate in the framework, and it is the one thing the REPLs deliberately do not have — there, the write verb is the function, and calling it fires it.

The honest trade runs the other way too. SQL cannot express conditional branching in one statement, so decide-and-act is two round trips; its exactly-once guarantee needed a whole pre-resolution subsystem to build; and a table needs modelling up front, which you cannot do for an arbitrary MCP server discovered at runtime. Functions need none of that. You can also mount both surfaces on one catalogue — measured at no cost, and models pick SQL for shaping data and the REPL for branching, without being told to.

A place where state accumulates

A REPL session keeps variables. It does not keep artifacts. When the work is a project rather than a query — forty PDFs to merge, a spreadsheet to reconcile, a deck to build and check — glove-working-environment gives the agent a sandboxed filesystem and a script runtime instead.

server.tstypescript
const env = await createWorkingEnvironment({
  stdlib: [documents(), spreadsheets(), render()],
  limits: { runTimeoutMs: 60_000 },
});

mountWorkingEnvironment(agent, { env });

The agent writes named, persistent scripts to /scripts and runs them in worker threads. Scripts can import only what the host injected — no network, no host filesystem, no process — so the sandbox is structural rather than policed. State survives between calls: write a script, run it, look at the intermediate, fix it, run it again.

And one verb changes what the whole thing can be trusted with. view_image lets the agent rasterize what it produced and look at it. A table running off the page, a chart with no bars, a title overlapping a figure — none of those are visible in the markup the agent generated. It is the difference between “the file was written” and “the deliverable is correct”.

Measured, not guessed. Across 90 runs on three open models, the single biggest failure was guessed import names. So the environment ships worked recipes in /skills with the exact import line for every module, and corrects a wrong import at write time rather than spending a run on it. Point your system prompt at /skills/README.md first. Six more bugs from this subsystem — every one of which reported success — are in Every failure in this one was silent.

Or just split the agent

The last answer is the one from section H, and it is often the right one: give each subagent the slice of the surface its job needs. This is the explicit recommendation for memory — do not attach the entity, episodic and resource tools to your main agent. Build a lookup subagent, a recall subagent, a find-notes subagent. The prompt surface scales with the role rather than with the ontology, and a reader-attached subagent cannot write because the affordance is not there — which is a better guarantee than any instruction in a prompt.

K. Remembering across conversations

Compaction keeps one conversation inside the window. It does nothing for the second conversation. glove-memory is five sibling subsystems, deliberately not one blob:

SubsystemShapeFor
EntityTyped graph with deterministic identityPeople, orgs, projects and how they relate
EpisodicAppend-only timelineMeetings, decisions, observations — time is a first-class field
ResourcesPOSIX-ish virtual filesystemNotes and transcripts the agent navigates with ls / grep
ContextInjected into the system prompt every turnStanding user preferences — “always ship to the office”
FormsStructured collection over a conversationSixty fields gathered by talking, not by a wizard

One design choice is worth stealing even if you never use the package: every write carries provenance — source, actor, timestamp, optional rationale — appended, never replaced. “Why does the agent believe this” stays answerable a year later.

Exposure is controlled on two independent axes, and you want both. Allowlists ({ tools: { deny: ["remove", "move"] } }) remove the affordance — the tool never reaches the model. Path policies (withResourceAccess) remove the capability — the adapter refuses the call however it arrives. And layerResources and its siblings merge a shared, read-only stratum with a private writable one into a single view: a team handbook underneath, the user's own notes on top, one coherent read, with writes routed to whoever owns the target.

Forms: the wizard, deleted

The fifth subsystem is the one that changes what an app looks like, so it gets its own section. A form — an insurance claim, an onboarding flow, a support intake — is conventionally a sequence of screens, and the sequence is the product: you cannot answer question six while being asked question two, and correcting question one means going back through three, four and five.

Glove forms delete the sequence and keep the structure. The definition is code — Zod schemas, gate closures and executors in one type-threaded chain — and the agent never reads it. It reads a projection of evaluated state.

forms/travel-claim.tstypescript
export const travelClaim = defineForm({
  id: "travel-claim",
  name: "Travel reimbursement claim",
  conduct: "Conversational — one or two questions at a time. Don't read the field list aloud.",
})
  .step("claimant", { title: "Claimant", preview: "name, staff id, email" }, (s) =>
    s
      .field("fullName", { schema: z.string().min(2), label: "Full name" })
      .field("email", { schema: z.string().email(), label: "Work email" }),
  )
  .step("travel", { title: "Travel", preview: "mode, mileage or ticket" }, (s) =>
    s
      .field("mode", { schema: z.enum(["car", "rail", "air"]), label: "Mode" })
      .field("mileage", {
        schema: z.number().int().min(1).optional(),
        label: "Miles driven",
        when: (v) => v.mode === "car",     // applicability, not ask-order
      }),
  )
  .checkpoint("policy-cap", {
    when: (v) => v.total > 750,
    blocking: true,
    run: () => ({ fail: "Over the limit — needs Finance pre-approval." }),
  })
  .onComplete(async (ctx) => {
    await ctx.memory.upsertNode("Person", { name: ctx.values.fullName });
  })
  .build();

Every .field() widens the accumulated values type, so ctx.values.mode narrows to its enum and ctx.values.mileage is number | undefined at every downstream callsite. There is no required option and no field-type vocabulary: a field is optional iff its schema accepts undefined, and the type description the agent reads (“email address”, “one of: car | rail | air”) is rendered from the schema. Both derived, neither declared.

Four properties fall out of that, and each one deletes a class of code you would otherwise write.

Writes are never gated. No locks, no “complete step 2 first”. A patch can carry any field ids at once, each validated independently so one bad value does not reject the rest, and an answer that is not applicable yet is held rather than dropped. A user who answers question six while being asked question two has answered question six. Field ids are forgiving too — full_name, Full name and fullName all land, and a miss comes back with did_you_mean.

Nothing is ever lost. Each field is an append-only log of revisions plus a cursor naming the one in force. A correction appends. A retraction is itself a revision — which makes retract, undo and redo pure cursor moves over a history that cannot drop an answer, and every one of them reversible. The agent reaches all four through one action parameter rather than four verbs, because tool schemas are re-sent every call and an eval put four verbs at ~75% of this surface's entire context cost.

Triggers steer the conversation. A checkpoint is a condition over values and statestepComplete(id), checkpointFired(id) — fired on its rising edge, and what it returns can move the conversation: { patch } to stamp a value, { jump } to route forward or back to a step that already finished, { fail } to record a rejection and carry on, { terminate } to stop collection outright when continuing would be wrong rather than merely unfinished. A backwards jump is a revisit, not a reset: the answers stay filled but come back asking, and the next write into that step releases the override.

It costs almost nothing to have. Tier 0 is a single line injected into the system prompt each turn — the open step, its pending field labels, and a one-line preview of what is still to come. Tier 1 is the open step in full, on request; tier 2 is any other step, one field, or the whole outline. Form modules are not even imported until an instance starts, so a registered sixty-field form costs a name and a description until someone needs it.

tier 0 — the whole standing costtext
[form: travel-claim] step 2/4 "Trip" · pending: Destination, Departure date
later: Travel (mode, mileage or ticket) · Approval (cost centre, manager)

Wiring is one call. useFormRunner folds the tools, wraps processRequest for that tier-0 line, and hands back the runner so your own code can start instances and resolve blocking checkpoints without going through the model at all.

agent.tstypescript
const { runner } = useFormRunner(glove, adapter, {
  registry,                    // lazy: { load: () => import("./forms/travel-claim") }
  subject: conversationId,
  memory: { entity, episodic, resources, context },   // executors can write to all four
});

That memory bridge is the point of putting forms in this package rather than in a package of their own. A completed claim is not a JSON blob to hand off — it is a person in the entity graph, an episode on the timeline, and a document in the resource tree, all written with engine-supplied provenance by an onComplete that runs on the commit that finished the form. The decisions behind all of that, and the five defects an agentic eval found that reading the code had not, are in Shipping Forms.

L. Realtime: a voice, and a face to put it on

Everything so far assumed typing. Realtime is where the same agent answers a phone call or looks back at you from a screen, and it is a stack you climb one layer at a time — each layer useful without the ones above it.

The cascade

glove-voice is speech → text → agent → text → speech. Every stage is an adapter you choose, the agent in the middle is untouched, and text streams into TTS sentence by sentence as the model produces it rather than waiting for the turn to finish.

The part worth knowing is what happens to noise. By default mic audio is speech-gated: it sits in a rolling pre-roll buffer and is released to the speech-to-text provider only once the voice detector confirms real speech survived a minimum duration. A door slam is never transcribed, never hallucinated into words, and never interrupts the agent mid-sentence — its audio is discarded outright. That is the difference between a demo and something usable in a kitchen.

Speech-to-speech

The cascade's latency is the sum of its parts. glove-voice-s2s collapses the stack: the model listens and speaks directly, and turn-taking is decided by something that can actually hear the caller. Your tools, display stack and context management all still apply — only the transport underneath changed.

agent.tstypescript
const agent = new Glove({
  store,
  // The model slot CARRIES the realtime config, so the agent definition stays
  // the single source of truth and RealtimeAgent derives the session from it.
  model: s2sDrivenModel({
    provider: "openai",                  // or "gemini"
    turnDetection: { type: "server_vad", silence_duration_ms: 450 },
  }),
  displayManager: new Displaymanager(),
  systemPrompt: "...",
  serverMode: true,
}).fold(bookTableTool);                  // tools work exactly as before

const rt = new RealtimeAgent({ agent });
await rt.start();

rt.sendAudio(pcm);                       // caller's mic in
rt.adapter.on("audio", (pcm) => { /* agent speech out */ });
rt.adapter.on("interrupted", () => { /* flush your playback */ });

Two details took the longest and matter the most. Turn-taking knobs are typed rather than raw JSON, because a silence threshold is the difference between an agent that talks over people and one that feels patient — that is a number you will tune, so it should autocomplete. And barge-in does truncation sync: when a caller cuts the agent off, the model is told what the caller actually heard, not what it had planned to say. Without it the agent carries on as though it delivered a sentence nobody received, and every later turn is built on that false belief.

The layering that works in practice. Keep the S2S model as a thin front agent — it is the ears and the mouth, and it should stay responsive. Heavy lookups get delegated to a capable worker agent over the mesh (section N). A realtime model held up mid-sentence by a three-second database query sounds exactly as bad as it is.

A face

An avatar provider is, structurally, a lip-sync renderer over an audio stream: PCM in, a talking face out on a WebRTC surface. Which is exactly the shape of the audio the S2S layer already emits — so glove-voice-avatar is a rendering layer over the stack rather than a replacement for any of it. The mic path, the tools and the delegation are untouched.

the whole integrationtypescript
const rt = new RealtimeAgent({ agent });
await rt.start();

const avatar = new TavusEchoAdapter({ apiKey: process.env.TAVUS_API_KEY! });
const detach = await attachAvatar(rt, avatar);
// audio → sendAudio · speech-stop → endUtterance · interrupted → interrupt

AvatarAdapter is the contract — connect() returns a view clients attach to (a WebRTC room URL, or an SDK session token, as a tagged union), plus sendAudio, endUtterance and an interrupt that is always safe to call. That last guarantee is not documentation, it is enforced: every adapter has to pass a behavioural conformance suite against a fake transport before it ships. Two do today — Tavus in echo mode and Anam in audio-passthrough mode, both configured so the provider's own language model and voice stay out of the loop. Your agent is the brain; the provider is the face.

WebRTC both ways

glove-voice-livekit replaces the hand-rolled audio duct with a real transport. The browser side shrinks to Room.connect plus a microphone toggle; barge-in becomes a server-authoritative buffer flush instead of a client-side race. And the avatars compose with it: the provider's worker joins your LiveKit room as a participant and publishes the voice on the agent's behalf — at which point you set publishAgentAudio: false, because otherwise the caller hears the agent twice.

Three packages rather than one, for the reason the layers are separable at all: a phone agent needs speech-to-speech and no avatar, a kiosk needs an avatar and no LiveKit, and nobody should carry three vendors' dependencies to use one of them. What the wire taught us while building all of that — including the bugs every test suite passed through — is its own post: what only live calls told us.

M. Image workflows

The same trajectory happens with images, and it is worth watching because it is the clearest example in the framework of a one-line tool turning into a subsystem.

You start with generate_image(prompt). It shells out to a provider and returns a URL, and it works — exactly once. The moment images are a repeated job, four things break at the same time:

glove-image makes each of those a named primitive.

agent.tstypescript
await mountImage(glove, {
  adapter: openrouterImages(),              // BYO image model
  assets: new InMemoryImageAssetStore(),    // where bytes live
  library: new InMemoryImageLibrary(),      // characters + scenes

  // The prompt pipeline: ordered "inbetweens" that build every request.
  pipeline: [
    expandCharacters(),                     // splices canonical wording verbatim
    expandScenes(),
    styleDirective("gouache, muted palette"),
    llmEnhance(),                           // one rewrite pass, characters preserved
  ],

  usage: meter,
  onUsage: (source, usage) => billing.record(source, usage),
});

A generation never sends raw model text to the image model. An intent becomes a draft, the draft runs through the inbetweens in order, and each stage appends a trace entry recording what it changed. The intent is never mutated — you can always see what was asked for next to what was actually sent.

Characters and scenes are durable identities whose wording is spliced into every prompt verbatim. That is the whole trick, and it is deliberately unglamorous: consistency comes from repetition, not from the model remembering. Promoting a good generation to a character reference image is the “lock in this look” move.

The last stage of the pipeline is always fitToModel(), appended whether you ask for it or not, and it is the one I would steal for any provider-backed feature. It reconciles the request against what the adapter can actually do — folds a negative prompt into the text when the model has no negative slot, drops unsupported reference roles, clamps the reference count identity-first, snaps the size, drops an unsupported seed — and writes every degradation into the trace. The request is never silently changed. You get told what you did not get.

Two more primitives fall out of having a pipeline at all. Lineage: every derived image records the recipe that made it, so “same, but at dusk” is one call that replays the recipe through the current library — edit a character, regenerate, and the edit is picked up. Cost: every model-touching call is metered at four scopes — per call, per image, per session, and per host — in real dollars where the provider reports them, including the enhancer's own tokens and any vision review.

Bytes never enter data. Model-facing results carry asset ids, dimensions and degradations; thumbnails ride on renderData for your renderers. Context cost is flat no matter how many images a session touches — which is section C's split doing real work.

And the honest part, which belongs in the docs as much as the capability does: these are generative approximations. Woven, textile and craft goods hold up well. A product carrying an exact logo, a precise colourway or fine hardware detail will not reproduce faithfully — not even with its own packshot pinned as a reference image.

N. More than one agent

Subagents are nested and synchronous — the parent waits. Peers are a different shape, and they are built on the inbox from section E.

glove-mesh gives agents direct messages, broadcasts and acknowledgements. When A sends to B, the framework drops a resolved inbox item into B's store, and B's existing inbox-injection path surfaces it on its next turn. No new agent-loop semantics — the mechanism was already there. A blocking send inserts a pending item that resolves on an ack or a reply, and a reply implies an ack, saving the recipient a round trip.

glove-continuum-signal supervises agents as subprocesses, in two modes. Triggered agents are cold: something wakes them, they resume a persistent store, run a turn, and go back to sleep — a background job whose body is a full agent. Concurrent agents stay warm in long-lived subprocesses and are notified inline, with no spawn latency.

The one that bites. A triggered agent without a persistent store gets a fresh memory on every wakeup, which defeats the entire point. Configure .store(name => …). Discovery warns you, but it warns into a log.

Z. Shipping it

Eventually the agent needs ffmpeg, or LibreOffice, or headless Chromium, and you do not want those on every machine that runs your app. Glovebox packages a built agent as a sandboxed container with one authenticated WebSocket endpoint.

glovebox.tstypescript
export default glovebox.wrap(agent, {
  name: "parcel-desk",
  base: "glovebox/docs",                     // pandoc, qpdf, ghostscript, libreoffice
  packages: { apt: ["poppler-utils"] },
  storage: {
    outputs: composite([
      rule.inline({ below: "1MB" }),         // small files ride the wire
      rule.localServer({ ttl: "1h" }),       // big ones get a URL
    ]),
  },
  env: { ANTHROPIC_API_KEY: { required: true, secret: true } },
  limits: { memory: "2GB", timeout: "10m" },
});
terminalbash
npx glovebox build ./glovebox.ts --out ./dist

Out comes a Dockerfile, a nixpacks recipe, a bundled server, a manifest and an auth key. Clients send a prompt and files and get back a message and output files; they never learn which tools fired. The agent, inside, gains a couple of things it did not have to be told about — a workspace skill that lists its mounts, and an /output hook for delivering files it wrote somewhere else.

The through-line

Read back over the list and the same shape keeps appearing. Model, store, display, subscriber, MCP, mesh, image model, memory, storage, transport — all adapters. Glove ships an in-memory reference implementation of nearly every one of them, and a production version of almost none.

That is on purpose, and it is the one opinion the framework really holds. The parts a framework should own are the ones that are the same everywhere: the loop, the tool contract, how a result is split between model and screen, what happens when the window fills. The parts it should not own are the ones where you already made a decision — your database, your auth, your queue, your vector index. A framework that ships those is asking you to run a second copy of infrastructure you already have.

Which means the honest way to start is small. One tool, one description written like you meant it, and the twenty-line script from section B. Add a piece from this page when something breaks — not before, because you will not know which one you needed until it does.

Everything here is MIT and on GitHub. The quickstart is the fifteen-minute version of section B, and examples/ has a runnable project for nearly every section above.