An agent can have its own web pages that appear in the conversation sidebar. The LLM triggers navigation with a `ctx.Send` action in the Next Hook, and the sidebar renders the page from the agent's `pages/` directory.
## Directory Structure
```
assistants/myns/myagent/
├── package.yao
├── pages/
│ ├── index/ ← route: /agents/myns.myagent/
│ │ ├── index.html ← template
│ │ ├── index.ts ← frontend TypeScript (event handlers)
│ │ ├── index.css ← styles
│ │ ├── index.backend.ts ← backend API (GetXxx / ApiGetXxx functions)
│ │ ├── index.json ← page config ($data binding, title)
│ │ └── __locales/ ← i18n strings
│ │ ├── en-us.yml
│ │ └── zh-cn.yml
│ └── detail/ ← route: /agents/myns.myagent/detail
│ ├── detail.html ← file name matches directory name
│ ├── detail.backend.ts
│ ├── detail.ts
│ └── detail.json
```
## Backend API
The `.backend.ts` file exposes two types of functions:
- **`GetXxx`** — called during page load via `$data: "@GetXxx"` in the `.json` config file; receives the HTTP `Request` object.
- **`ApiGetXxx` / `ApiPostXxx`** — called dynamically from the frontend via `$Backend().Call("ApiGetXxx", ...)`.
`Authorized()` is a global function provided by the SUI runtime — declare it to resolve `team_id`.
```typescript
// pages/index/index.backend.ts
import { Process } from "@yao/runtime";
declare function Authorized(): { team_id?: string } | null;
interface Request { authorized?: { team_id?: string } }
// Called on page load via index.json: { "$data": "@GetRecords" }
function GetRecords(request: Request) {
const teamId = request.authorized?.team_id || Authorized()?.team_id;
const MODEL = "agents.myns.myagent";
return Process(`models.${MODEL}.record.Get`, {
wheres: [{ column: "team_id", value: teamId }],
orders: [{ column: "created_at", option: "desc" }],
limit: 50,
});
}
// Called dynamically from the frontend
function ApiGetRecords(page: number, pageSize: number) {
const teamId = Authorized()?.team_id;
const MODEL = "agents.myns.myagent";
return Process(`models.${MODEL}.record.Paginate`, {
wheres: [{ column: "team_id", value: teamId }],
orders: [{ column: "created_at", option: "desc" }],
}, page, pageSize || 50);
}
```
The `.json` config file links `GetRecords` to the page's initial data:
```json
{
"title": "My Agent",
"$data": "@GetRecords"
}
```
## HTML Template
SUI pages use a component-based HTML template with data binding:
```html
<!-- pages/index/index.html -->
<div id="records-index" class="page">
<!-- include shared nav component -->
<div is="/myns.myagent/nav" active="index"></div>
<main class="main">
<!-- s:for loops over the "records" data array -->
<div s:for="{{ records }}" s:for-item="record" class="record-item">
<h3>{{ record.title }}</h3>
<p>{{ record.content }}</p>
<!-- s:on-click binds to a frontend event handler -->
<button s:on-click="OpenRecord" s:data-id="{{ record.id }}">Open</button>
</div>
</main>
</div>
```
### SUI Template Directives
| Directive | Description |
|-----------|-------------|
| `s:for="{% raw %}{{ array }}{% endraw %}"` | Repeat element for each item |
| `s:for-item="name"` | Variable name for the current item inside `s:for` |
| `s:on-click="Handler"` | Bind click event to a frontend function |
| `s:data-*="{% raw %}{{ value }}{% endraw %}"` | Pass data to the event handler |
| `s:trans` | Mark text content for i18n translation |
| `is="/path/component"` | Include a SUI component |
## Frontend Event Handlers
```typescript
// pages/index/index.ts
function OpenRecord(event: Event, data: { id: string }) {
// navigate to the detail page
window.location.href = `/agents/myns.myagent/detail?id=${data.id}`;
}
function SearchEntries() {
const query = (document.getElementById("search-input") as HTMLInputElement).value;
// re-fetch data from backend
// SUI provides a built-in reload mechanism via the data binding system
}
```
## Triggering Navigation from the Agent
In the Next Hook, use `ctx.Send` with `type: "action"` and `name: "navigate"` to open a sidebar page:
```typescript
// src/index.ts
export function Next(ctx, payload) {
const recordId = payload.completion?.content;
// navigate the sidebar to the detail page
ctx.Send({
type: "action",
props: {
name: "navigate",
payload: {
route: `/agents/myns.myagent/detail?id=${recordId}`
}
}
});
return null;
}
```
The user sees the page appear in the conversation sidebar immediately after the LLM responds.
## i18n
Place translation strings in `__locales/`:
```yaml
# pages/index/__locales/en-us.yml
index:
title: My Agent
search_placeholder: Search...
empty: No results found
# pages/index/__locales/zh-cn.yml
index:
title: 我的 Agent
search_placeholder: 搜索...
empty: 没有找到结果
```
Use in templates with `s:trans` for the element's text content, or `{{ '::key' }}` for inline values:
```html
<h1 s:trans>My Agent</h1>
<input placeholder="{{ '::index.search_placeholder' }}" />
```
## Multiple Pages
A typical agent has two or three pages:
| Page | Route | Purpose |
|------|-------|---------|
| `index/` | `/agents/myns.myagent/` | List / browse view |
| `detail/` | `/agents/myns.myagent/detail?id=xxx` | Single-record detail view |
| `settings/` | `/agents/myns.myagent/settings` | Configuration |
Use a shared component for navigation between pages:
```html
<div is="/myns.myagent/nav" active="index"></div>
```
## What's Next
- **[Open Source Projects →](./open-source)** — See all of this working together in real projects