> ## Documentation Index
> Fetch the complete documentation index at: https://trueforge.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Bring your own server

> Implement AgentUIServer and pass the object straight to the component.

Use this when your backend owns the whole contract — sessions, chat streaming, and the agent builder. You implement `AgentUIServer` and pass the object straight to the component, with no `type` wrapper: every call the UI makes, including the `createTurn` event stream, hits your endpoints.

Because `server` accepts an `AgentUIServer` directly, you can front any backend — including your own proxy that keeps API keys server-side. The object is used immediately, so there is no loading indicator on mount.

## Minimal implementation

The shape below is the smallest thing that mounts. Optional methods are omitted; required ones must exist even if they only throw in paths your UI never reaches.

List methods return a `ListResult<T>` — a plain object of `{ data, nextPageToken? }`. There are no methods on it, so you can return a parsed JSON body directly as long as it carries those two fields.

```ts title="server.ts" theme={null}
import type { AgentUIServer, Session } from "@truefoundry/trueforge-ui";

const now = new Date().toISOString();
const session: Session = {
  id: "s1",
  isMutable: true,
  createdAt: now,
  updatedAt: now,
};

export const server: AgentUIServer = {
  // chat
  async createSession() {
    return session;
  },
  async listSessions() {
    return { data: [session] };
  },
  async getSession() {
    return session;
  },
  async updateSession() {
    return session;
  },
  async *createTurn({ sessionId, input }) {
    const res = await fetch(`/api/sessions/${sessionId}/turns`, {
      method: "POST",
      body: JSON.stringify({ input }),
    });
    let seq = 0;
    for await (const event of parseEventStream(res)) {
      yield { sequenceNumber: seq++, event };
    }
  },
  async cancelSession({ sessionId }) {
    await fetch(`/api/sessions/${sessionId}/cancel`, { method: "POST" });
  },
  async listTurns() {
    return { data: [] };
  },
  async getTurn({ turnId }) {
    throw new Error(`unknown turn ${turnId}`);
  },
  async listEvents() {
    return { data: [] };
  },

  // builder
  async getCapabilities() {
    return { data: { sandbox: { enabled: false }, skill: { enabled: false } } };
  },
  async getModels() {
    return [
      { id: "my-model", name: "My model", provider: { name: "my-provider" }, properties: {} },
    ];
  },
  async getSkills() {
    return [];
  },
  async getMcp() {
    return [];
  },
  async searchAgents() {
    return [{ agentId: "support-agent", name: "Support agent" }];
  },
  async saveAgent() {
    return {};
  },
};
```

Declaring `sandbox` and `skill` as disabled is the honest starting point — the UI then hides those affordances instead of offering controls your backend cannot serve. Flip them on as you implement each.

```tsx title="App.tsx" theme={null}
<TrueForgeUI server={server} layout="sidebar" agentConfig={{ mode: "AgentLibrary" }} />
```

## Streaming

`createTurn` yields envelopes of the form `{ sequenceNumber, event }` — not bare events. The number must increase monotonically within the turn, and it is what lets a reconnecting client resume via `subscribeToTurn({ afterSequenceNumber })`.

The minimum viable stream for one assistant reply is three events:

```ts theme={null}
async *createTurn({ sessionId, input }) {
  const turnId = crypto.randomUUID();
  const threadId = sessionId;
  let seq = 0;
  const at = () => new Date().toISOString();

  yield {
    sequenceNumber: seq++,
    event: { type: "turn.created", id: crypto.randomUUID(), turnId, createdAt: at() },
  };

  // stream text as it arrives; reuse one message id across all deltas
  const messageId = crypto.randomUUID();
  for await (const piece of myModelStream(input)) {
    yield {
      sequenceNumber: seq++,
      event: {
        type: "model.message.delta",
        id: messageId,
        threadId,
        content: piece, // the increment, not the running total
      },
    };
  }

  yield {
    sequenceNumber: seq++,
    event: {
      type: "turn.done",
      id: crypto.randomUUID(),
      state: { status: "done", completedAt: at() },
      createdAt: at(),
    },
  };
}
```

Two things are easy to get wrong. Deltas carry the *increment* rather than the accumulated text, and every delta for one message shares a single `id`. And a stream that ends without a `turn.done` leaves the UI showing the turn as still running.

[Streaming events](/ui-sdk/reference/events) documents the full protocol — tool calls, approval prompts, sub-agent threads, and sandbox artifacts.

## Pagination

`ListResult<T>` is `{ data: T[]; nextPageToken?: string }`. Return the next cursor in `nextPageToken`; omit it (or leave it `undefined`) on the last page. Tokens travel the other way too — list requests accept a `pageToken`, which the SDK feeds back to fetch the following page.

```ts theme={null}
async listSessions({ pageToken, limit = 20 } = {}) {
  const res = await fetch(`/api/sessions?limit=${limit}&cursor=${pageToken ?? ""}`);
  const body = await res.json(); // { data, pagination }
  const token = body.pagination?.nextPageToken;
  return {
    data: body.data,
    ...(token === undefined ? {} : { nextPageToken: token }),
  };
}
```

Spreading `nextPageToken` in only when present keeps the result valid under `exactOptionalPropertyTypes`; assigning `undefined` to the optional field directly is rejected there.

`listEvents` additionally accepts `lastTurnId`, used when hydrating history for an existing session.

## Optional capabilities

<AccordionGroup>
  <Accordion title="deleteSession / deleteAgent">
    In a hand-written `AgentUIServer` like the one above, omitting these leaves the corresponding affordances unavailable rather than broken. With [`createTrueForgeServer`](/ui-sdk/reference/server#factory), an omitted `deleteAgent` throws when called.
  </Accordion>

  <Accordion title="downloadSandboxFile">
    Return a `Blob`. Required only if your agents produce sandbox artifacts users should be able to download.
  </Accordion>

  <Accordion title="subscribeToTurn">
    Resumes a live stream from `afterSequenceNumber`, so a reconnecting client picks up where it left off instead of replaying the turn. Skip it if your backend cannot resume.
  </Accordion>

  <Accordion title="catalog">
    Attach a [`CatalogServer`](/ui-sdk/reference/catalog) to enable the settings UI — models, connectors, skills, and sandboxes. Without it those surfaces do not render.
  </Accordion>
</AccordionGroup>

## Widening the types

The interfaces are generic so you can carry backend-specific fields through the UI without casting. `AgentUIServer` itself is a plain alias, so compose the widened chat and builder ports directly:

```ts theme={null}
interface MyModel extends ModelSelection {
  modelId: string;
}

type MySkillMount = { fqn: string; preload: boolean };
type MySpec = AgentSpec<Model, MySkillMount>;

const server: AgentChatServer<MySpec> & AgentBuilderServer<MySpec, MyModel> = {
  /* ... */
};
```

See [Widening the types](/ui-sdk/setup-custom-servers/server-contract#widening-the-types) for the full set of parameters.

## Attachments

Attachment handling defaults to `trueForgeAttachmentAdapter`. Override it through the `adapters` prop on `TrueForgeUI` if your backend stores uploads differently.

```tsx theme={null}
<TrueForgeUI server={server} layout="sidebar" adapters={{ attachments: myAdapter }} />
```


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.