Show your work
A surface is how people reach the agent and see what it is doing. In Glove it is two primitives: an event stream (text as it arrives, tool calls as they happen) and a display stack that tools push UI onto, and can wait on. The second is what lets a tool stop and ask a person, then carry on with their answer.
The display stack in one paragraph
Every tool receives display as its second argument. display.pushAndForget({ renderer, input }) renders something and lets the tool continue: a card, a table, progress. await display.pushAndWait({ renderer, input }) renders something and suspends the tool until the person resolves it, then returns their answer to the tool, not to the model. (With glove-react's defineTool, below, the renderer is the tool itself, so you pass just the props.) Nothing about this is tied to chat: the same stack drives a web UI, a terminal prompt, or a permission dialog in an IDE.
Put the release assistant on the web
With glove-react and glove-next, the model call goes through a one-line route, the tools run in the browser next to their components, and anything that needs server credentials is a fetch to your own API.
pnpm add glove-react glove-next zodimport { createChatHandler } from "glove-next";
export const POST = createChatHandler({
provider: "anthropic",
model: "claude-sonnet-4-20250514",
});import { GloveClient, defineTool } from "glove-react";
import { z } from "zod";
const change = z.object({ number: z.number(), title: z.string(), labels: z.array(z.string()) });
// Shows the notes and keeps going: pushAndForget.
const showNotes = defineTool({
name: "show_release_notes",
description: "Show draft release notes to the user. Use after drafting them.",
inputSchema: z.object({ version: z.string(), changes: z.array(change) }),
displayPropsSchema: z.object({ version: z.string(), changes: z.array(change) }),
async do(input, display) {
await display.pushAndForget(input);
return { status: "success", data: "Shown to the user." };
},
render({ props }) {
return (
<section>
<h3>Release {props.version}</h3>
<ul>{props.changes.map((c) => <li key={c.number}>#{c.number} {c.title}</li>)}</ul>
</section>
);
},
});
// Asks and waits: pushAndWait. The model never sees the button, only the outcome.
const tagRelease = defineTool({
name: "tag_release",
description: "Tag and publish a release. Always confirms with the user first.",
inputSchema: z.object({ version: z.string() }),
displayPropsSchema: z.object({ version: z.string() }),
resolveSchema: z.object({ confirmed: z.boolean() }),
displayStrategy: "hide-on-complete",
unAbortable: true,
async do({ version }, display) {
const { confirmed } = await display.pushAndWait({ version });
if (!confirmed) return { status: "error", data: null, message: "The user cancelled." };
const res = await fetch("/api/releases", { method: "POST", body: JSON.stringify({ version }) });
return { status: "success", data: await res.json() };
},
render({ props, resolve }) {
return (
<div>
<p>Tag and publish {props.version}?</p>
<button onClick={() => resolve({ confirmed: true })}>Publish</button>
<button onClick={() => resolve({ confirmed: false })}>Cancel</button>
</div>
);
},
});
export const gloveClient = new GloveClient({
endpoint: "/api/chat",
systemPrompt: "You help a software team ship releases. Use tools for facts; never guess.",
tools: [
{
name: "list_merged_changes",
description: "List pull requests merged on or after a date, newest first.",
inputSchema: z.object({ since: z.string() }),
async do({ since }) {
const changes = await (await fetch(`/api/changes?since=${since}`)).json();
return { status: "success", data: changes }; // plain tools return ToolResultData
},
},
showNotes,
tagRelease,
],
});"use client";
import { GloveProvider, Render, useGlove } from "glove-react";
import { gloveClient } from "@/lib/glove";
function Chat() {
const glove = useGlove();
return (
<Render
glove={glove}
renderMessage={({ entry }) => <p><b>{entry.kind === "user" ? "You" : "Ship"}:</b> {entry.text}</p>}
renderStreaming={({ text }) => <p style={{ opacity: 0.7 }}>{text}</p>}
renderInput={({ send, busy }) => (
<form
onSubmit={(e) => {
e.preventDefault();
const input = e.currentTarget.elements.namedItem("msg") as HTMLInputElement;
if (input.value.trim()) send(input.value.trim());
input.value = "";
}}
>
<input name="msg" disabled={busy} placeholder="Draft notes for 2.4.0" />
</form>
)}
/>
);
}
export default function Page() {
return <GloveProvider client={gloveClient}><Chat /></GloveProvider>;
}<Render> places each tool's slots in the conversation where they were pushed, applies the display strategy (hide-on-complete removes the confirm once answered), and renders history with renderResult when you add one. useGlove() exposes the same timeline if you would rather draw it yourself.
Ask before every risky call, once
A confirm inside one tool is fine. For a policy ("ask before any write"), set requiresPermission on the tool instead: true for every call, or a function of the input, such as (input) => input.command !== "git status". Glove asks through the display stack with a permission_request slot, stores the decision per tool and input, and returns a clear error to the model when the answer is no. You render that one slot type once for every gated tool; the coding agent shows it. In step 7 the same flag becomes an approval record that anyone with access can resolve, even hours later.
Other surfaces, same agent
| Surface | What you use |
|---|---|
| Terminal or backend | Subscribers for streaming; displayManager.subscribe() to see slots and resolve(slotId, value) to answer them. See server-side agents. |
| WebSocket app | Forward subscriber events and slots over the socket; the coding agent does exactly this. |
| Voice | GloveVoice wraps the same Glove: speech in, speech out, barge-in. Voice pipeline. |
| Realtime speech or an avatar | RealtimeAgent runs the same tools on OpenAI Realtime, Gemini Live or GPT-Live. Realtime voice. |
| Any OpenAI SDK, Slack, email | Foundry's APIs and transmissions, in step 7. |
Already built for you
| Primitive | What you get | Instead of building |
|---|---|---|
| Display stackglove-core | Tools render UI mid-conversation; pushAndWait suspends the tool until the person answers.display.pushAndForget({ renderer, input }) · await display.pushAndWait({ renderer, input }) | a side channel from tools to the UI and a pause/resume protocol |
| React bindingsglove-react | Tools with colocated, typed renderers, a hook with the whole timeline, and headless rendering.defineTool({ …, render, renderResult }) · useGlove() · <Render /> | a chat UI state machine |
| Streaming routeglove-next | An SSE endpoint for Next.js (or any fetch handler) that a GloveClient talks to.export const POST = createChatHandler({ … }) | an SSE protocol between your server and client |
| Voice pipelineglove-voice | VAD → STT → agent → TTS with barge-in, push-to-talk and React Native backends.new GloveVoice(glove, { stt, createTTS, vad }) · useGloveVoice(…) · useGlovePTT(…) | stitching speech-to-text, the agent and text-to-speech together |
| Realtime speech and avatarsglove-voice-s2s · glove-voice-avatar · glove-voice-livekit | The same tools on OpenAI Realtime, Gemini Live or GPT-Live, with live avatars and LiveKit rooms.new RealtimeAgent({ agent }) with model: s2sDrivenModel({ provider }) | a second tool implementation for the realtime provider |
| Voice callsglove-foundry/voice | A Foundry agent as a live call: same prompt, tools and memory, transcript saved, delegation results spoken at the next pause.run: voiceCall({ room, greeting }) · webSocketRoom({ port, token }) | a hand-written call server around RealtimeAgent |
Next
The assistant can show notes and ask before publishing. But it only knows what is in the conversation. In step 3 we control what it sees on every call: the release in progress, the team's conventions, and a plan it keeps up to date.