References & providers¶
Let users attach things to a message — files and your own domain entities
(tables, dashboards, tickets, customers, …). Everything is driven by
reference providers you register on <KabooProvider references={…}> and the
KabooReferenceInput composer slot.
This guide covers what a provider is, the two transports, and how to write your own. The backend half — making a reference actually reach an agent — is covered in kaboo-workflows' Attachments & Multimodal chapter.
Mental model¶
A provider is a pluggable source of "things you can reference" in the shared
+ / @ popover. It answers three questions:
- What can be referenced? — the items it lists (
search) or the action it runs (onSelect, e.g. a file picker). - How is it shown in the picker? — its
label,icon, and optionalrenderItem. - What does a chosen one become on the wire? —
toReference, which maps a selection to aPendingReferencewith a transport.
When the user picks an item, the composer stages it as a chip and, on submit, serializes it onto the outgoing message according to its transport.
Providers don't style chips
A provider controls how an item looks in the popover menu (icon,
renderItem) and what it becomes (toReference). It does not control
how the chip renders in the editor or the sent bubble — that is centralized
in KabooReferenceInput / KabooUserMessage and derived from the
reference's transport, mime type, and name.
The two transports¶
Every reference reaches the agent as one of two transports:
| Transport | For | Carries bytes? | Serialized as | Resolved by |
|---|---|---|---|---|
attachment |
files/blobs (pdf, image, csv, …) | yes | a CopilotKit InputContent part on the message |
the built-in fetch_attachment tool |
object |
pointers to your entities | no | an entry in state.kaboo_references |
your MCP tool (per kind) |
attachment is the only blob-capable transport. An object is just a pointer
{ kind, id, name }; your backend resolves it on demand.
Register providers¶
Pass providers to KabooProvider and use the KabooReferenceInput slot:
```tsx no-verify import { CopilotChat } from "@copilotkit/react-core/v2"; import { KabooProvider, KabooReferenceInput, uploadProvider, } from "@pgege/kaboo-react"; import { KabooMessageView } from "@pgege/kaboo-react/copilotkit"; import { tableProvider } from "./references";
<KabooProvider runtimeUrl="/api/copilotkit" agent={agent} threadId={threadId} references={[uploadProvider({ accept: "image/*,.pdf", onUpload }), tableProvider]}
; ## Anatomy of a `ReferenceProvider` ```ts no-verify interface ReferenceProvider<T = unknown> { id: string; // stable provider id label: string; // group heading in the menu icon?: ReactNode; // icon beside the heading search?: (query: string) => // omit → action-only ReferenceItem<T>[] | Promise<ReferenceItem<T>[]>; renderItem?: (item: ReferenceItem<T>) => ReactNode; // custom menu row onSelect?: (item: ReferenceItem<T>) => void | Promise<void>; // side effect on choose toReference?: (item: ReferenceItem<T>) => PendingReference; // becomes this on the wire } // Each searchable item; `data` is provider-defined and threaded to toReference. interface ReferenceItem<T = unknown> { id: string; label: string; // default chip text description?: string; // optional secondary line data?: T; }
A provider is either:
- searchable — supplies
search, so its items list live in the menu as the user types; or - action-only — omits
search; selecting its group row runsonSelect(e.g. opening a file dialog). The built-inuploadProvideris action-only.
Write an object provider¶
Three fields make a custom entity referenceable — search (what to list),
toReference (what it becomes), and icon (how it's grouped):
import { Table2 } from "lucide-react";
import type { ReferenceProvider } from "@pgege/kaboo-react";
const TABLES = [
{ id: "companies", label: "companies", description: "Vendors and company profiles" },
{ id: "products", label: "products", description: "Product catalog with pricing" },
];
export const tableProvider: ReferenceProvider = {
id: "table",
label: "Tables",
icon: <Table2 size={16} />,
// Items listed under @ for the current query.
search: (q) =>
TABLES.filter((t) => t.label.toLowerCase().includes(q.toLowerCase())),
// How a chosen item serializes onto the message.
toReference: (item) => ({
transport: "object",
kind: "table",
id: item.id,
name: item.label,
meta: { description: item.description },
}),
};
The selected object syncs into state.kaboo_references and appears inline as an
@name chip. Your backend resolves the { kind: "table", id } pointer with its
own tool. Add more object kinds (dashboards, tickets, …) by registering more
providers with distinct kinds.
meta rides along
Anything you put in toReference(...).meta travels in state.kaboo_references
and is available to your resolver tool — handy for extra context the id
alone doesn't capture.
search may be async and is called on every keystroke. Every provider is
searched at once, a search that rejects drops that provider's rows rather than
the menu, and an answer that arrives after the user has typed further is
discarded — so a slow provider costs latency, never wrong rows. Debouncing or
caching on your side is still worth it if the query hits a network.
Files: the built-in uploadProvider¶
File upload ships as a provider. Register it with your onUpload:
import { uploadProvider } from "@pgege/kaboo-react";
import type { AttachmentUploadResult } from "@copilotkit/shared";
async function onUpload(file: File): Promise<AttachmentUploadResult> {
const form = new FormData();
form.append("file", file);
const res = await fetch("/api/upload", { method: "POST", body: form });
const { url, mimeType } = await res.json();
return { type: "url", value: url, mimeType }; // a fetchable URL
}
uploadProvider({ accept: "image/*,.pdf,.csv,.txt,.md", maxSize: 20 * 1024 * 1024, onUpload });
- Return a URL, not base64. The server fetches attachment URLs with no auth (presigned/public pattern), which keeps the AG-UI transport and the persisted event log small.
onUploadis optional. Omit it and the file is inlined as base64 — fine for small files/tests, but it bloats storage for large ones.acceptandmaxSizegate the picker; oversized files are rejected before upload.
Choosing "Attach a file" (from + or @) opens the picker, uploads, and
drops a file chip. The file rides the message as an InputContent part stamped
with kaboo_id / kaboo_kind / kaboo_name metadata.
Action-only providers¶
Omit search to make the group row itself an action. Use onSelect for the
side effect; produce the reference either synchronously via toReference or
asynchronously (as uploadProvider does through its upload callback):
```tsx no-verify
const pickFromDrive: ReferenceProvider = {
id: "drive",
label: "Google Drive",
icon:
## What a chosen item becomes
`toReference` returns a `PendingReference`:
```ts
type PendingReference =
| { transport: "attachment"; kind: string; id: string; name: string;
mimeType: string; source: { url: string } | { data: string } }
| { transport: "object"; kind: string; id: string; name: string;
meta?: Record<string, unknown> };
On submit the composer:
- appends each attachment as a message content part (with the
kaboo_*metadata), and - writes each object into
state.kaboo_references.
The id you mint is the same id the backend lists in its manifest and passes to
a resolver tool, so both stacks agree without translation.
What providers do not do¶
- They don't render the selected chip (editor or bubble) — that's centralized.
- They don't decide the per-agent policy (
reference/inline/ tool availability) — that's the backend'sattachments:config.
Next steps¶
- Getting started — wire kaboo-react into a CopilotKit app.
- Theming — restyle chips and the composer via CSS variables.
- kaboo-workflows Attachments & Multimodal — how references reach each agent
(manifest,
fetch_attachment, inline media).