Give agents a world / Conversations & shared work

Conversations & shared work

One agent instance can hold any number of independent conversations, and every instance in a workspace shares four durable records: key/value entries, a shared inbox, a task list and read-only environment values. Your host code and the agent's built-in tools read and write the same records.

What lives where

An instance belongs to exactly one workspace (its workspaceId, default "default"). A conversation belongs to one instance. The workspace is just that shared id: anything stored under it is visible to every instance and conversation in it.

RecordScopeStored inCreated by
Conversationone instancethe Foundry data adapter (metadata only)host API, transmissions, spawn, the OpenAI-compatible API, the inspector
Message historyone conversationthe definition's store or the application's conversationStorethe Glove loop during a run
Workspace entryworkspacedata adapterhost API, glove_foundry_workspace, your tools
Shared inbox itemworkspacedata adapterhost API, glove_foundry_shared_inbox, background reconvene
Taskworkspacedata adapterhost API, glove_foundry_tasks
Environment valueworkspace, instance or conversationdata adapterthe adapter's environment seed option only
!

Every run executes in its own child process, and that process builds its data adapter from foundry.application.ts. With no data configured, each process gets a private MemoryFoundryDataAdapter, so what an agent writes with the built-in tools is invisible to the host and to the next run. Configure a shared adapter such as FileFoundryDataAdapter (single host) or your own database adapter. See Persistence.

The conversation record

glove-foundry (primitives.ts)typescript
interface Conversation {
  readonly id: string;           // default: "conversation_<uuid>"
  readonly agentId: string;      // owning instance
  readonly workspaceId: string;  // default: the instance's workspaceId
  readonly title?: string;
  readonly context: Readonly<Record<string, unknown>>; // your metadata
  readonly createdAt: string;
  readonly updatedAt: string;    // bumped on every message
}
  • A conversation can only receive messages if its workspaceId equals its instance's. Otherwise the run is refused with “The request, agent instance, and conversation must share a workspace.” Choose the workspace when you create the instance; leave workspaceId off conversations.
  • Creating a conversation with an id that already exists is an error.
  • Titles set through an update must be 1–120 characters.
  • context is yours. Foundry writes its own keys only on conversations it creates (routeId, threadKey, conversationKey, conversationScope for transmissions; spawnedByRunId for spawned work; protocol for /v1 sessions).

Create, message, list and rename

Two customers write to the same support instance at once. Each gets its own conversation, so histories never mix, and both runs proceed in parallel.

scripts/support-threads.tstypescript
import { createFoundryClient } from "glove-foundry/client";

const foundry = createFoundryClient({
  baseUrl: process.env.FOUNDRY_URL ?? "http://127.0.0.1:4141",
});

// One instance of agents/support/agent.ts in the "acme" workspace.
const support = await foundry.createAgent("support", { workspaceId: "acme" });

const billing = await foundry.createConversation(support.id, {
  title: "Order 4411 — double charge",
  context: { customerId: "cus_42", channel: "web" },
});
const shipping = await foundry.createConversation(support.id, {
  title: "Order 4412 — late delivery",
});

const [first] = await Promise.all([
  foundry.send(support.id, billing.id, "I was charged twice for order 4411."),
  foundry.send(support.id, shipping.id, "Where is order 4412?"),
]);
const done = await first.wait({ timeoutMs: 120_000 });
console.log(done.status, done.output);

const threads = await foundry.conversations(support.id);
await foundry.updateConversation(support.id, billing.id, {
  title: "Order 4411 — refunded",
});
Client methodHTTPNotes
createConversation(agentId, { id?, title?, context?, workspaceId? })POST /api/conversationsBody adds agentId. 201.
conversations(agentId)GET /api/conversations?agent=agent is required.
updateConversation(agentId, id, { title?, context? })PATCH /api/conversations/:idcontext replaces the whole object.
send(agentId, id, message, { payload?, context? })POST /api/conversations/:id/messages202 with the run. message is a string or Glove content parts.
conversationTranscript(agentId, id, { offset?, limit? })GET /api/conversations/:id/messages?agent=See below.

To ship fixed conversations with the code (a website chat thread, a voice line), seed them into the data adapter. Seeds are inserted only when their id is absent, so restarts keep existing data.

foundry.application.tstypescript
import {
  FileFoundryDataAdapter,
  createConversation,
  defineAgentInstance,
  defineApplication,
} from "glove-foundry";
import support from "./agents/support/agent.js";

const supportDesk = defineAgentInstance(support, { id: "support-desk", workspaceId: "acme" });

export default defineApplication({
  name: "Support",
  data: new FileFoundryDataAdapter({
    file: ".foundry-data/state.json",
    agents: [supportDesk],
    conversations: [
      createConversation(supportDesk, { id: "support-web", title: "Website chat" }),
    ],
  }),
});

Inside a run, the built-in glove_foundry_conversations tool lists the current instance's conversations.

Read transcripts

A transcript is the exact native Glove history read from the conversation store the agent itself uses. Foundry never rebuilds history from runs or events. The built-in WebChat uses the same call.

scripts/support-threads.tstypescript
const transcript = await foundry.conversationTranscript(support.id, billing.id, { limit: 50 });

if (!transcript.persisted) console.warn("support has no conversation store configured");
for (const message of transcript.messages) console.log(`${message.sender}: ${message.text}`);

if (transcript.page.hasEarlier) {
  const earlier = await foundry.conversationTranscript(support.id, billing.id, {
    offset: Math.max(0, transcript.page.offset - 50),
    limit: 50,
  });
}
FieldMeaning
conversationThe conversation record.
messagesNative Glove Message[] for the requested page.
turnCount, tokenCount, tokenConsumption?The store's own counters.
page{ offset, limit, total, hasEarlier, hasLater }. limit defaults to 100 (1–500). Without offset you get the latest page.
persistedfalse when neither the definition's store nor the application's conversationStore is set; messages is then empty.
!

persisted: true only means a store factory exists. The control process calls that factory to read the transcript, so a factory that returns a fresh new MemoryStore(...) (as the starter templates do) reads back nothing. Use a durable store for real history.

Conversations created by transmissions

When an inbound transmission event reaches an instance, Foundry finds or creates the conversation for that thread. You never create these yourself, and the same thread always lands in the same conversation.

conversationScopeConversation idUse for
"route" (default)transmission:<routeId>:<agentId>:<encoded key>One conversation per provider thread per route.
"agent"an existing conversation whose context.conversationKey equals the key, else transmission:agent:<agentId>:<encoded key>Joining several routes (email, chat, phone) into one conversation per customer.

The key is conversationKey when supplied, otherwise the provider threadKey. A connection passes both in its receive input (ConnectionReceiveInput); a webhook bridge passes them to POST /api/transmissions/:routeId/fire:

scripts/forward-ticket.tstypescript
const runs = await foundry.dispatchInbound({
  routeId: "tickets-inbound",
  eventId: "evt_9001",            // dedupe key: the same event never runs twice
  threadKey: "ticket-4411",       // provider thread
  conversationKey: "customer:cus_42",
  conversationScope: "agent",     // reuse this customer's conversation across routes
  raw: { type: "message", text: "Any update?" },
});

To attach a transmission thread to a conversation you created yourself, put the same key in its context — createConversation(agentId, { context: { conversationKey: "customer:cus_42" } }) — and deliver with conversationScope: "agent". More than one conversation with the same key is an error. Only use "agent" scope when a trusted adapter produced the key. See Apps & transmissions.

The workspace primitives

PrimitiveShapeStatusesTypical use
Workspace entry{ workspaceId, key, value, updatedAt }—Shared decisions, policies, artifact references, claims.
Shared inbox item{ id, workspaceId, agentId?, conversationId?, topic, payload, status, createdAt, updatedAt }pending · resolved · dismissedHandoffs and results addressed to an instance or conversation.
Task{ id, workspaceId, agentId?, conversationId?, title, detail?, status, createdAt, updatedAt }open · in-progress · completed · cancelledUnits of work with an owner and progress.
Environment value{ key, value, scope, workspaceId, agentId?, conversationId? }—Non-secret configuration such as a region or escalation address.

From host code

scripts/support-ops.tstypescript
await foundry.putWorkspaceEntry("acme", "refund-policy", { maxAutoRefund: 50, currency: "USD" });
const entries = await foundry.workspaceEntries("acme");

const handoff = await foundry.postSharedInbox("acme", {
  topic: "escalation",
  payload: { orderId: "4411", reason: "refund above the auto-approval limit" },
  agentId: support.id,          // optional addressee
  conversationId: billing.id,   // optional
});
await foundry.updateSharedInbox("acme", handoff.id, "resolved");

const task = await foundry.createTask("acme", {
  title: "Refund order 4411",
  detail: "Duplicate charge confirmed by payments.",
  agentId: support.id,
  conversationId: billing.id,
});
await foundry.updateTask("acme", task.id, "in-progress");

// Workspace values, plus agent- and conversation-scoped ones when ids are given.
const values = await foundry.dataEnvironment("acme", { agentId: support.id, conversationId: billing.id });
Client methodHTTP
workspaceEntries(ws) · putWorkspaceEntry(ws, key, value)GET · PUT /api/workspaces/:ws/entries (body { key, value })
sharedInbox(ws) · postSharedInbox(ws, input)GET · POST /api/workspaces/:ws/inbox (new items are pending)
updateSharedInbox(ws, itemId, status)PATCH /api/workspaces/:ws/inbox/:itemId (body { status })
tasks(ws) · createTask(ws, input)GET · POST /api/workspaces/:ws/tasks (new tasks are open)
updateTask(ws, taskId, status)PATCH /api/workspaces/:ws/tasks/:taskId
dataEnvironment(ws, { agentId?, conversationId? })GET /api/workspaces/:ws/environment?agent=&conversation=

There is no delete endpoint for entries, inbox items or tasks, and no write endpoint for environment values; putWorkspaceEntry is last-write-wins.

From the agent: built-in tools

Every assembled run gets these tools automatically (alongside the scheduling and spawn tools covered in Automation). They operate on the run's own workspace; an agent cannot reach another workspace through them.

ToolInputBehaviour
glove_foundry_workspace{ action: "get", key } · { action: "set", key, value } · { action: "list" }set is last-write-wins.
glove_foundry_shared_inbox{ action: "list" } · { action: "post", topic, payload, agentId?, conversationId? } · { action: "update", itemId, status }Posts default the addressee to the current instance and conversation; new items are pending.
glove_foundry_tasks{ action: "list" } · { action: "create", title, detail? } · { action: "update", taskId, status }Created tasks are owned by the current instance and conversation, status open.
glove_foundry_environment{}Lists workspace values plus this instance's and this conversation's.
glove_foundry_conversations{}Lists this instance's conversations.

Your own tools over ctx.data

For anything the built-ins don't cover, write a tool with the resolver form of tools. The assembly context carries the data adapter as ctx.data (every method returns an Effect) plus workspaceId, agentId and conversationId. This is also how agents use compareAndSetWorkspaceEntry, the adapter's optional atomic update: pass null to require that the key does not exist yet, or the current updatedAt to require that nobody changed it; the new entry must carry a later updatedAt. It returns false when someone else won. Both built-in adapters implement it.

agents/support/agent.tstypescript
import { createAdapter } from "glove-core/models/providers";
import { defineAgent } from "glove-foundry";
import { Effect } from "effect";
import { z } from "zod";
import { FileStore } from "../../lib/file-store.js"; // durable transcript — see Persistence

export default defineAgent({
  description: "Answers customer support tickets",
  model: () => createAdapter({ provider: "openrouter", model: "openai/gpt-4.1-mini", stream: true }),
  systemPrompt: "You resolve support tickets. Claim a ticket before you work on it.",
  store: ({ conversationId }) => FileStore.open(conversationId),
  tools: (_agent, ctx) => [
    {
      name: "claim_ticket",
      description: "Claim a ticket so no other agent works on it at the same time.",
      inputSchema: z.object({ ticketId: z.string() }),
      async do({ ticketId }) {
        const attempt = ctx.data.compareAndSetWorkspaceEntry?.(
          {
            workspaceId: ctx.workspaceId,
            key: `claim:${ticketId}`,
            value: { agentId: ctx.agentId, conversationId: ctx.conversationId },
            updatedAt: new Date().toISOString(),
          },
          null, // succeed only if nobody has claimed it yet
        );
        if (!attempt) {
          return { status: "error" as const, data: null, message: "This data adapter has no atomic update." };
        }
        const claimed = await Effect.runPromise(attempt);
        return { status: "success" as const, data: { ticketId, claimed } };
      },
    },
  ],
});

ctx.data exists on the assembly context used by resolvers. The contexts passed to defineCall handlers, layers and run do not carry it, so capture it in a resolver as above.

Environment values

Environment values are seeded through the environment option of MemoryFoundryDataAdapter or FileFoundryDataAdapter and are read-only afterwards. A value with scope: "agent" needs agentId; "conversation" needs conversationId. They are not process.env and must never hold secrets — keep credentials in adapters.

foundry.application.tstypescript
new FileFoundryDataAdapter({
  file: ".foundry-data/state.json",
  environment: [
    { scope: "workspace", workspaceId: "acme", key: "region", value: "eu-west" },
    { scope: "agent", workspaceId: "acme", agentId: "support-desk",
      key: "escalationEmail", value: "tier2@acme.example" },
  ],
});

Lazy inboxes: feed results into the next turn

The shared inbox is Foundry data; the model never sees it unless a tool reads it. The inboxes field bridges the two: a resolver (it must be a function) that returns native Glove InboxItem[] for the current run. Foundry loads them into the conversation store before the loop starts, and Glove does the rest:

  • resolved items are injected into the next model turn as an inbox message, then marked consumed in the store.
  • pending items with blocking: true add a notice that dependent actions must wait.
  • Loading adds unknown ids and updates status/response/resolved_at of known ones. It never deletes. Returning an item again as resolved re-injects it, so hand each item over once.
  • The store must be inbox-capable (MemoryStore from glove-core is).
agents/support/agent.tstypescript
import type { InboxItem } from "glove-core";
import { Effect } from "effect";

// inside defineAgent({ ... })
inboxes: async (_agent, ctx) => {
  const shared = await Effect.runPromise(ctx.data.listInboxItems(ctx.workspaceId));
  const ready = shared.filter(
    (item) => item.conversationId === ctx.conversationId && item.status === "resolved",
  );
  for (const item of ready) {
    // Dismissed items are not loaded again.
    await Effect.runPromise(ctx.data.putInboxItem({
      ...item,
      status: "dismissed",
      updatedAt: new Date().toISOString(),
    }));
  }
  return ready.map((item): InboxItem => ({
    id: item.id,
    tag: item.topic,
    request: `Shared inbox item "${item.topic}"`,
    response: JSON.stringify(item.payload).slice(0, 4_000),
    status: "resolved",
    blocking: false,
    created_at: item.createdAt,
    resolved_at: item.updatedAt,
  }));
},

When inboxes is set, each run emits foundry.definition.inboxes.loaded with { count, added, updated }. Mesh messages use the same native inbox; see Multi-agent.

Pass artifacts by reference

Put a pointer in shared data, not a document in a prompt: a workspace entry such as { key: "order-4411/summary", value: { path: "/out/refund-4411.md", producedBy: runId } }, or a shared inbox payload with the same shape. The reader opens the canonical file and the history of who wrote it stays inspectable. A reference only helps if the reader can resolve it: agents inside one run (subagents, an in-process mesh) can share one working environment, while separate instances need storage both can read, such as your own VFS persistence keyed by workspace or an object-store key. Foundry's own VFS snapshots are private to an agent or conversation and are never exposed as workspace entries. See Working environments.

Background work and reconvene

glove_foundry_background lets the model start more work without waiting for it. Input: { message, payload?, definitionId?, reconvene? }; reconvene defaults to true. The tool returns { commandId, accepted: true, type: "background" } at once, and the runtime queues a new run with the same instance and the same conversation (source.kind: "background").

With reconvene on, Foundry waits for that run (up to 60 seconds) and then posts a shared inbox item to the calling conversation: topic: "background.reconvene", status: "resolved", payload: { commandId, run }. If the run is still going after 60 seconds the item records its unfinished status. Nothing wakes the conversation: the result reaches the model on its next turn only if you load it, for example with the inboxes resolver above.

◆

Because background runs share the caller's conversation history, prefer glove_foundry_spawn (a new conversation by default) for long or independent work, and use the shared inbox or a task to report back. See Multi-agent systems.