Objects
Objects are visual components that tools can create. When a tool creates an object, it shows up as a tab in the UI where users can interact with it.
What Objects Do
Tools return data. But sometimes you want more than raw data - you want a visual representation. A spreadsheet. A chart. A document editor.
Objects solve this. They're persistent entities with custom renderers. A tool creates an object, and the UI renders it using your custom code.
Defining Objects
Objects are defined in objects.json:
[
{
"name": "note-editor",
"title": "Note",
"renders": ["web"],
"capabilities": ["storage"],
"metadata_schema": {
"type": "object",
"properties": {
"noteId": {
"type": "string",
"description": "ID of the note to display"
}
},
"required": ["noteId"]
}
}
]
Object Fields
| Field | Required | What It Does |
|---|---|---|
name |
Yes | Identifier (kebab-case) |
title |
Yes | Display name in UI tabs |
renders |
Yes | Where to render: ["web"], ["cli"], or both |
capabilities |
No | What the renderer can access |
lifecycle |
No | Enable update/unmount hooks |
metadata_schema |
Yes | Schema for object references |
Metadata vs Data
Objects have metadata and data. They're different:
- Metadata is stored with the object (in the object registry)
- Data is stored separately (in your app's storage)
The metadata_schema defines references, not actual content. Your renderer fetches the real data:
{
"metadata_schema": {
"type": "object",
"properties": {
"noteId": { "type": "string" }
}
}
}
This stores just the noteId. The renderer uses that ID to fetch the actual note content.
Creating Objects
Tools with the object capability can create objects:
export default async function create_note(
input: Input,
capabilities: Capabilities
): Promise<Output> {
const noteId = crypto.randomUUID();
// Save the actual note data to storage
await capabilities.storage
.use('@my-org/notes')
.put(`/notes/${noteId}.json`, {
title: input.title,
content: input.content
});
// Create an object that references this note
await capabilities.object.set('@my-org/notes', {
type: 'note-editor',
name: input.title,
metadata: { noteId }
});
return { noteId, success: true };
}
The object appears as a tab in the UI. When users click it, your renderer loads the actual data.
Building Renderers
Renderers are TypeScript files that return DOM elements. Create them in src/objects/{name}/web.ts:
// src/objects/note-editor/web.ts
import { ObjectInfo, Capabilities, ObjectLifecycle } from './types.js';
export default async function web(
info: ObjectInfo,
capabilities: Capabilities,
lifecycle: ObjectLifecycle
): Promise<HTMLElement> {
const container = document.createElement('div');
container.className = 'note-editor';
// Fetch the actual note data
const note = await capabilities.storage
.use('@my-org/notes')
.get(`/notes/${info.metadata.noteId}.json`);
container.innerHTML = `
<h1>${note.title}</h1>
<div class="content">${note.content}</div>
`;
return container;
}
Renderer Capabilities
Renderers can access capabilities declared in objects.json:
| Capability | What It Provides |
|---|---|
storage |
Read/write to storage |
tool |
Call tools from the renderer |
ai |
Access AI completions |
// Using the tool capability
const result = await capabilities.callTool('@my-org/notes', 'update_note', {
noteId: info.metadata.noteId,
content: newContent
});
Lifecycle Hooks
Enable lifecycle hooks for dynamic updates:
{
"name": "note-editor",
"lifecycle": true
}
Then use them in your renderer:
export default async function web(
info: ObjectInfo,
capabilities: Capabilities,
lifecycle: ObjectLifecycle
): Promise<HTMLElement> {
const container = document.createElement('div');
// Called when object data changes
lifecycle.onDataUpdated((newMetadata) => {
refreshContent(container, newMetadata);
});
// Called when object is removed from view
lifecycle.onUnmount(() => {
cleanup();
});
return container;
}
Updating Objects
Tools can update existing objects:
// Update an existing object
await capabilities.object.set('@my-org/notes', {
type: 'note-editor',
id: existingObjectId, // Pass the existing ID
name: 'Updated Title',
metadata: { noteId }
});
If you pass an id, the object is updated. If you don't, a new object is created.
Deleting Objects
Remove objects when they're no longer needed:
await capabilities.object.delete('@my-org/notes', {
type: 'note-editor',
id: objectId
});
Cross-App Objects
Objects can be stored in another app's namespace for team sharing:
{
"name": "shared-doc",
"storage": {
"inApp": "@my-org/shared",
"path": "/teams/<token.teamId>/objects/",
"tokenType": "team",
"tokenFromApp": "@my-org/auth"
}
}
This stores objects in @my-org/shared storage, scoped by team.
Example: Table Viewer
Here's a complete example of a table object:
objects.json:
[
{
"name": "table-view",
"title": "Table",
"renders": ["web"],
"capabilities": ["storage", "tool"],
"lifecycle": true,
"metadata_schema": {
"type": "object",
"properties": {
"tableId": { "type": "string" }
},
"required": ["tableId"]
}
}
]
src/objects/table-view/web.ts:
export default async function web(
info: ObjectInfo,
capabilities: Capabilities,
lifecycle: ObjectLifecycle
): Promise<HTMLElement> {
const container = document.createElement('div');
async function render() {
const data = await capabilities.storage
.use('@my-org/tables')
.get(`/tables/${info.metadata.tableId}.json`);
container.innerHTML = `
<table>
<thead>
<tr>${data.columns.map(c => `<th>${c}</th>`).join('')}</tr>
</thead>
<tbody>
${data.rows.map(row =>
`<tr>${row.map(cell => `<td>${cell}</td>`).join('')}</tr>`
).join('')}
</tbody>
</table>
`;
}
await render();
lifecycle.onDataUpdated(() => render());
return container;
}
Objects turn your tools from data-in-data-out functions into full interactive experiences.