Build it up · Step 2 of 8

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.

Surfacechat · voice · APIContextprompt · history · live stateModelany providerStatestore · memory · inboxToolsvalidated · gatedTeamsubagents · meshWorkspacefiles · code · webloopuntil the model answersprompttool callsresultshistorymessageanswerdisplay stack: show · ask · waita subagent is a toolacts on
One request through a Glove harness. The message is saved to state; context is assembled from state before every model call; the model asks for tools; results are saved; the cycle repeats until the model answers. Tools can render UI on the surface and wait for a reply, act on a workspace, or hand work to a subagent.

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.

terminalbash
pnpm add glove-react glove-next zod
app/api/chat/route.tstypescript
import { createChatHandler } from "glove-next";

export const POST = createChatHandler({
  provider: "anthropic",
  model: "claude-sonnet-4-20250514",
});
lib/glove.tsxtsx
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,
  ],
});
app/page.tsxtsx
"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

SurfaceWhat you use
Terminal or backendSubscribers for streaming; displayManager.subscribe() to see slots and resolve(slotId, value) to answer them. See server-side agents.
WebSocket appForward subscriber events and slots over the socket; the coding agent does exactly this.
VoiceGloveVoice wraps the same Glove: speech in, speech out, barge-in. Voice pipeline.
Realtime speech or an avatarRealtimeAgent runs the same tools on OpenAI Realtime, Gemini Live or GPT-Live. Realtime voice.
Any OpenAI SDK, Slack, emailFoundry's APIs and transmissions, in step 7.

Already built for you

PrimitiveWhat you getInstead of building
Display stackglove-coreTools 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-reactTools 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-nextAn 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-voiceVAD → 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-livekitThe 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/voiceA 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.

The whole path

  1. 01A loop and a toolLoop · Tools
  2. 02Show your workSurface
  3. 03Control what it seesContext
  4. 04Make it rememberState
  5. 05Give it a workspaceWorkspace
  6. 06Split the workTeam
  7. 07Make it a systemIdentity · Time · Arrival · Oversight · Access
  8. 08Put it in productionRun · Keep · Reach · Watch