A loop and a tool
The smallest harness is a model, one tool, and the loop between them. Across these eight steps we build one agent: a release assistant for a software team. It starts here as a script that answers "what shipped this week?" and ends as a deployed system that people message, that wakes on its own, and that asks before it tags a release.
Install
pnpm add glove-core zod
export ANTHROPIC_API_KEY=sk-ant-...The whole agent
import { Glove, Displaymanager, MemoryStore } from "glove-core";
import { createAdapter } from "glove-core/models/providers";
import { z } from "zod";
// Stand-in for your Git host's API.
const merged = [
{ number: 412, title: "Retry failed webhooks", mergedAt: "2026-10-20", labels: ["fix"] },
{ number: 415, title: "Export invoices as CSV", mergedAt: "2026-10-21", labels: ["feature"] },
{ number: 418, title: "Bump Node to 22.13", mergedAt: "2026-10-22", labels: ["chore"] },
];
const agent = new Glove({
store: new MemoryStore("release-assistant"),
model: createAdapter({ provider: "anthropic", model: "claude-sonnet-4-20250514" }),
displayManager: new Displaymanager(),
systemPrompt:
"You help a software team ship releases. Use tools for facts about the code; never guess.",
compaction_config: {
compaction_instructions: "Summarise the release work so far: what is merged, what is decided.",
},
})
.fold({
name: "list_merged_changes",
description: "List pull requests merged on or after a date, newest first.",
inputSchema: z.object({
since: z.string().describe("ISO date, e.g. 2026-10-19"),
}),
async do({ since }) {
const changes = merged.filter((pr) => pr.mergedAt >= since).reverse();
return { status: "success", data: changes };
},
})
.build();
const result = await agent.processRequest("What has shipped since the 19th?");
// A hook can answer with a single Message; normally it is the model's result.
console.log("messages" in result ? result.messages[0]?.text : result.text);A Glove needs a model, a display manager (the surface tools can render to; empty for now), a system prompt and compaction instructions. The store, where the conversation lives, is optional and defaults to a fresh MemoryStore. fold adds a tool (before or after build); build binds the store and makes the agent runnable; processRequest runs the loop until the model answers without asking for a tool.
What the loop already does
That processRequest call is doing more than calling the model in a loop. Each of these is something people write by hand the first time:
- Validation. Input is checked against the Zod schema before your code runs. A bad call returns
TOOL_INPUT_INVALIDwith the field errors, so the model corrects itself instead of your tool crashing. - Unknown tools. A misspelled tool name gets an error listing the tools that exist and the nearest match.
- Retries. A tool that throws is retried (3 times by default,
maxRetries) before the error goes back to the model. - Pairing. Every tool call is saved with its result before anything else happens, so history never holds an orphaned call that a provider would reject.
- A turn limit. One request stops after 120 model turns by default (
compaction_config.max_turns) and says so, so a confused agent can't loop forever. - Compaction. When the conversation nears the context limit, it is summarised with your instructions. The store keeps every message for your UI; the model sees the summary.
- Token accounting. Input, output and prompt-cache tokens are counted per call and kept in the store.
Stream it, stop it, switch models
A script wants output as it arrives. Subscribers receive typed events from every stage of the loop; an AbortSignal stops a request between steps.
import type { SubscriberEventDataMap as Events } from "glove-core";
agent.addSubscriber({
async record(type, data) {
if (type === "text_delta") process.stdout.write((data as Events["text_delta"]).text);
if (type === "tool_use") {
const call = data as Events["tool_use"];
console.error(`\n→ ${call.name}`, call.input);
}
if (type === "token_consumption") console.error("tokens", (data as Events["token_consumption"]).consumption);
},
});
const controller = new AbortController();
process.on("SIGINT", () => controller.abort());
await agent.processRequest("Anything risky in this release?", controller.signal);
// Same agent, same history, another provider:
agent.setModel(createAdapter({ provider: "openrouter", model: "openai/gpt-4.1-mini" }));createAdapter covers Anthropic, OpenAI, Gemini, OpenRouter, Bedrock, the Vercel AI Gateway, Kimi, GLM, MiniMax, MiMo, Ollama and LM Studio. Pass cache: true for prompt caching where the provider needs it asked for. Your tools never change between providers.
Write tools the model can use
- The description is the interface. The model picks tools by reading it. Say what it returns and when to use it.
- Return small, structured data. A tool result stays in context for every later turn. Return what the model needs to decide, and put anything only your UI needs in
renderData, which the model never sees. - Return
status: "error"with a message for expected failures (not found, not allowed) so the model can react. Throw only for real faults, which get retried. - Mark tools that must finish (a payment, a write that can't be half-done) with
unAbortable: true. Abort waits for them.
Already built for you
Everything Glove gives you for the loop and for tools. Several of these, such as MCP, code-as-tools and classifiers, come up again in later steps.
| Primitive | What you get | Instead of building |
|---|---|---|
| Agent loopglove-core | Model → tool calls → results → model, until it answers. Matched tool call/result pairs, retries, a per-request turn limit.new Glove({ model, store, displayManager, systemPrompt, compaction_config }).build().processRequest(text) | a while-loop that calls the model, parses tool calls, runs them and appends results |
| Model adaptersglove-core | Anthropic, OpenAI, Gemini, OpenRouter, Bedrock, Vercel AI Gateway, Kimi, GLM, MiniMax, MiMo, Ollama and LM Studio behind one interface, with streaming, reasoning and prompt caching.createAdapter({ provider: "anthropic", model, cache: true }) | provider SDK glue, a streaming parser and tool-call format translation per provider |
| Hot-swap model and promptglove-core | Change the model or prompt between requests without rebuilding the agent or losing history.glove.setModel(adapter) · glove.setSystemPrompt(text) | tearing the agent down to switch models |
| Abort and un-abortable toolsglove-core | Cancel a request mid-loop; tools that must finish (a payment, a write) run to completion first.processRequest(text, signal) · fold({ ..., unAbortable: true }) | custom cancellation flags threaded through every tool |
| Subscribersglove-core | Typed events for text deltas, tool calls and results, compaction, tokens and subagents: stream them, log them, meter them.glove.addSubscriber({ record(type, data) { … } }) | wrapping the loop to stream text, log tool calls or count tokens |
| Toolsglove-core | Zod-validated input, results the model reads, optional renderData the model never sees.glove.fold({ name, description, inputSchema, do }) | hand-written JSON schemas, input validation and a dispatch switch |
| Permission gatesglove-core | Ask before a tool runs, per call or always; decisions are stored per tool and input. In Foundry it becomes an approval record.fold({ ..., requiresPermission: (input) => input.destructive }) | confirm-before-delete logic inside each tool |
| Tools that change at runtimeglove-core | Add tools mid-session or swap a whole set atomically.glove.fold(tool) after build · glove.replaceTools(previousNames, nextTools) | rebuilding the agent when the available tools change |
| Tool result summariesglove-core | Large results replaced by a short summary in later requests.enableToolResultSummary: true · fold({ ..., generateToolSummary }) · do() returns generateSummaryArgs | truncating big tool results by hand |
| MCP serversglove-mcp | Any MCP server's tools as Glove tools, a discovery subagent that activates servers mid-conversation, OAuth helpers.mountMcp(glove, { adapter, entries }) | an MCP client and a schema bridge |
| Code as the tool surfaceglove-js · glove-python · glove-lisp | One execute tool over a catalogue of functions: the model writes a program that loops, filters and branches, and only the answer returns to context.mountJs(glove, { session: JsSession.create() }) | fifty tool definitions and a round-trip per step |
| Capabilities as tablesglove-scratchpad | Resources become SQL tables behind one execute_sql tool, with discovery, joins and staged writes.mountDatabase(glove, { db }) | a list/get/search tool for every resource |
| Classifiersglove-classifier | Fast, calibrated yes/no, choice and score answers from classifier models, with an LLM fallback and confidence gates.mountClassifier(glove, { classifier: jev() }) | a chat model call for every routing or triage decision |
| Image and video workflowsglove-image · glove-video | Prompt pipelines, durable characters and scenes, references, review, lineage and cost tracking.mountImage(glove, { adapter, assets, library }) · mountVideo(…) | a one-shot generate_image tool with no consistency between images |
Next
The agent answers, but only in a terminal, and it can't ask anyone anything. In step 2 it gets a surface: streaming into a web UI, tools that render components, and a tool that waits for a person to confirm.