# erza
`erza` is a terminal-native UI language, runtime, and thin app protocol for
docs, tools, and small product surfaces.
This `SKILLS.md` file is the AI-agent guide for `erza`.
Browser URL:
`https://erza.ryangerardwilson.com/skills`
Worked example URL:
`https://erza.ryangerardwilson.com/example`
The human-facing overview lives in `README.md`.
## Read This First
If you are an AI agent with no prior context, treat this file as the operating
manual.
`erza` is:
- a frontend-first application framework for the terminal
- a `.erza` authoring language
- a curses runtime
- a thin backend protocol for actions, forms, auth, and remote app loading
- a reusable `erza.chat` runtime API for conversation-list and chat-style TUIs
`erza` is not:
- a browser UI framework
- a generic CLI scaffolding tool
- a full backend framework with opinions about databases, jobs, or infra
The standard app shape is:
```text
my_app/
index.erza
backend.py # Python local prototype backend, optional today
```
## If You Only Remember 13 Things
1. Build a single `index.erza`, not a folder of pages.
2. Top-level `<Section>` blocks are tabs.
3. Tabs render within one screen, not as separate documents.
4. Forms are modal-only.
5. A modal is either a single-form modal or a view modal that only opens form modals.
6. `ButtonRow` is the standard action surface.
7. `ui.*` actions are runtime-local. They do not call the backend.
8. Non-`ui.*` actions are backend actions.
9. `<Form action="/path">` is for backend writes with explicit route paths.
10. `<Link href="...">` is for navigation.
11. Use `hjkl` as the primary interaction model. Arrow keys are secondary aliases.
12. Default visual direction is terminal-native, keyboard-first, and low-chrome.
13. For chat-style apps, use `erza.chat` before writing another custom curses loop.
## Mental Model
An `erza` app is usually one terminal surface with these layers:
- `<Screen>`: the root container
- `<Splash>`: optional startup screen shown before the main app
- top-level `<Section>`: tabs across the app
- top-level `<Modal>`: overlays opened from tabs or page actions
- `<Form>`: write flows inside modals
- `backend(...)`: read-side template access
- actions and routes: write-side interaction contract
Think of it like this:
- `Screen` is the app shell
- `Section` is a tab/page inside that shell
- `Modal` is the overlay system
- `Form` is the write surface
- `ButtonRow` is the canonical action strip
Directionally, `erza` is closer to a terminal-native single-surface React app than
to a multi-page website.
## Build Rules
When authoring an app, follow these rules.
- Prefer one `index.erza` file per app.
- Put app-level tabs at the top level with `<Section>`.
- Use `tab-order` to set tab order.
- Use `default-tab="true"` to choose the first selected tab.
- Use direct-action tabs only for things like `Logout`.
- Put read-only context in pages or view modals.
- Put write flows in form-only modals.
- Use `ButtonRow` instead of scattering ad-hoc actions through content.
- Keep nested boxes meaningful. Use them to show hierarchy, not decoration.
- Keep backgrounds transparent or terminal-default.
- Do not assume a custom font.
- Keep the UI usable with `hjkl` alone.
- For conversation/message/composer apps, prefer `erza.chat.ChatCallbacks` and
keep the app-specific API calls in the host app.
- Use `<!-- ... -->` for source comments when you need to annotate a real `.erza` file.
## Action Contract
This is the most important thing to understand if `erza` feels unusual.
`on:press` is an action name, not always a URL.
There are four different write/navigation paths:
### 1. Runtime-local UI actions
Example:
```erza
<Action on:press="ui.open_modal" modal:id="new-post">New post</Action>
```
This does not call the backend. The runtime intercepts `ui.open_modal` and opens
a modal locally.
The `ui.*` namespace is reserved for runtime behavior.
### 2. Backend actions
Example:
```erza
<Action on:press="feed.like" post:id="42">Like</Action>
```
This is a backend action.
- in local apps, the runtime dispatches it directly through the backend bridge
- in remote apps, the runtime sends it to the standardized action endpoint
You do not see a route path inline because backend actions are command-based, not
route-based.
### 3. Form routes
Example:
```erza
<Form action="/posts">
```
This is route-based. Forms always submit to explicit paths.
### 4. Links
Example:
```erza
<Link href="docs.erza">Docs</Link>
```
Links are explicit navigation targets.
## Remote Protocol
A remote `erza` app is opened by host or URL. The current protocol is:
- `GET /.well-known/erza?path=/requested/path`
- `POST /.well-known/erza/action?path=/requested/path`
- `POST /.well-known/erza/auth`
Remote form submits post JSON directly to the form `action` URL.
### Remote action request
```json
{
"action": "feed.like",
"params": {"post_id": 42}
}
```
### Remote auth request
```json
{
"username": "ryan",
"password": "secret"
}
```
### Standard result contract
Forms and auth use the same result shape:
```json
{
"type": "refresh"
}
```
```json
{
"type": "redirect",
"href": "index.erza"
}
```
```json
{
"type": "error",
"message": "Something went wrong"
}
```
This is the key portability boundary. A backend can be written in any language if
it speaks this contract.
## Backend Model
Today, the built-in local backend path is Python, but the product boundary is
still language-agnostic. Node.js and other languages fit through the remote
protocol cleanly.
Read side:
- `@handler("name")` exposes a function to templates through `backend("name")`
Write side:
- `@route("/path")` handles form submissions
- backend actions handle `on:press="some.action"`
- `session()` exposes per-user session state
Use Python first if you want the fastest local path. Use Node.js or another
language when you are building a remote host that implements the protocol.
## Start Building
### 1. Create the app shell
Start with one `index.erza`.
```erza
<Screen title="Town Square">
<Section title="Profile" tab-order="0">
<Header>@ryan</Header>
<Text>No description yet.</Text>
</Section>
<Section title="Feed" tab-order="1" default-tab="true">
<Header>Town Square</Header>
<Text>Welcome to erza.</Text>
<ButtonRow align="right">
<Action on:press="ui.open_modal" modal:id="new-post">New post</Action>
</ButtonRow>
</Section>
<Modal id="new-post" title="New Post">
<Form action="/posts">
<Input name="body" type="text" label="Post" required="mandatory" />
<ButtonRow align="right">
<Submit>Publish</Submit>
</ButtonRow>
</Form>
</Modal>
</Screen>
```
### 2. Add a backend
Python local example using `backend.py`:
```python
from erza.backend import handler, redirect, route, session
@handler("ui.status")
def ui_status() -> str:
return session().get("status", "Welcome to erza.")
@route("/posts")
def create_post(body: str = ""):
session()["status"] = f"Posted: {body.strip()}"
return redirect("index.erza")
```
Node.js remote-host example using the protocol:
```js
import express from "express";
const app = express();
app.use(express.json());
let status = "Welcome to erza.";
app.get("/.well-known/erza", (req, res) => {
res.type("application/erza").send(`
<Screen title="Town Square">
<Section title="Feed" default-tab="true">
<Header>Town Square</Header>
<Text>${status}</Text>
<ButtonRow align="right">
<Action on:press="ui.open_modal" modal:id="new-post">New post</Action>
</ButtonRow>
</Section>
<Modal id="new-post" title="New Post">
<Form action="/posts">
<Input name="body" type="text" label="Post" required="mandatory" />
<ButtonRow align="right">
<Submit>Publish</Submit>
</ButtonRow>
</Form>
</Modal>
</Screen>`);
});
app.post("/posts", (req, res) => {
status = `Posted: ${String(req.body.body || "").trim()}`;
res.json({ type: "redirect", href: "index.erza" });
});
app.post("/.well-known/erza/action", (req, res) => {
res.json({ type: "refresh" });
});
app.listen(3000);
```
### 3. Read backend state in the template
```erza
<? status = backend("ui.status") ?>
<Text><?= status ?></Text>
```
### 4. Run it
```bash
python app/main.py run path/to/app
```
If `path/to/app` is a directory, `erza` resolves `index.erza` automatically.
### 5. Read a real app
If you want a realistic reference instead of a toy example, read `koinonia/index.erza`.
It is currently `268` lines and shows that `erza` can express a small social-media
app in under `300` lines, including:
- auth-gated tabs
- post compose flows
- profile editing
- reply/view-replies modal flows
- thread viewing
### 6. Keep the backend distinction straight
Do not confuse these two backend paths:
- `backend.py` is the built-in Python local prototype path in this repo
- Node.js is supported by implementing the remote `erza` protocol on your host
## Core Authoring Rules
### Screen
`<Screen title="...">` is the root.
Supported root structure today:
- zero or one `<Splash>`
- top-level `<Section>` tabs
- top-level `<Modal>` overlays
### Sections
Top-level sections are tabs.
- use `tab-order="N"` for ordering
- use `default-tab="true"` for default activation
- use conditional template logic to change tabs by login state
- use a direct action section for flows like `Logout`
### Modals
There are only two valid modal types.
#### Form modal
A form modal contains exactly one `<Form>`.
Use it for:
- login
- signup
- compose
- edit profile
- reply
- any other write flow
#### View modal
A view modal contains no form.
It may only contain actions that open form-only modals.
Use it for:
- viewing replies
- viewing context
- choosing a next write action without mixing reading and writing
### Forms
Forms are modal-only.
Current form behavior:
- opening a form modal auto-focuses the first input
- `Enter` commits the current input and moves into the next input when possible
- form inputs use the shared Erza input editor
- Ctrl-A/Ctrl-E, Ctrl-B/Ctrl-F, Alt-B/Alt-F, Ctrl-W/Ctrl-H, and Ctrl-D/Ctrl-K/Ctrl-U work consistently in text inputs
- submit buttons live in a `ButtonRow`
- multi-submit forms are supported with multiple `<Submit>` buttons
- `ascii-art` inputs enforce a frontend width limit of `72` columns
### ButtonRow
`ButtonRow` is the standard action strip.
- it is full-width
- it is horizontally scrollable
- alignment can be `left`, `center`, or `right`
- inside a form, it should contain `<Submit>` buttons
- outside a form, it may contain actions or links
### Splash
Use `<Splash>` for startup screens and `<SplashAnimation>` for ASCII logo motion.
```erza
<Splash duration-ms="1400">
<SplashAnimation fps="7">
<Frame>...</Frame>
<Frame>...</Frame>
</SplashAnimation>
</Splash>
```
## Chat Runtime API
Use `erza.chat` when the app is fundamentally a conversation list plus a
message transcript and composer. This is the right layer for Slack DM/GDM views,
Telegram-style chats, support inboxes, and similar tools.
The app provides data and side-effect callbacks:
```python
from erza.chat import ChatCallbacks, ChatConversation, ChatMessage, run_chat_app
callbacks = ChatCallbacks(
load_conversations=lambda: [
ChatConversation("D1", "maanas", "2026-04-27 10:00", kind="dm"),
],
load_messages=lambda conversation: [
ChatMessage("D1:1", "Maanas", "2026-04-27 10:00", "hello"),
],
send_message=lambda conversation, text: None,
mark_read=lambda conversation, messages: None,
mark_all_read=lambda conversations: None,
open_file=lambda conversation, message, file: "/tmp/file.txt",
)
run_chat_app(callbacks, title="slack tui")
```
`erza.chat` owns:
- conversation list rendering and navigation
- boxed message rendering
- inline embed boxes
- `<<<X Files>>>` file buttons
- fixed-height file modal
- default normal mode and `i`-to-insert composition
- insert-mode Esc back to normal mode
- shared Erza input editing for the composer
- Ctrl-N/Ctrl-P message movement
- Erza matrix loading overlay for slow chat callbacks
- file opening with PDF/image defaults and editor fallback
The host app owns:
- auth
- API calls
- message and conversation ordering
- read-state mutation
- file downloads
- domain-specific errors
Read `CHAT_SURFACES_SPEC.md` before adding or changing chat behavior.
## Template Model
`.erza` files use HTML-like tags plus PHP-style template blocks.
Supported template features:
- `<?= expr ?>`
- `<? name = expr ?>`
- `<? if expr ?> ... <? else ?> ... <? endif ?>`
- `<? for item in items ?> ... <? endfor ?>`
- `backend("handler.name", **kwargs)` inside expressions
The expression language is intentionally small.
It supports:
- literals
- lists and dictionaries
- attribute access like `post.title`
- boolean logic
- simple comparisons
- `backend(...)` calls
## Component Reference
Common authoring components:
- `<Screen title="...">`
- `<Section title="...">`
- `<Header>`
- `<Text>`
- `<AsciiArt>`
- `<Link href="...">`
- `<Action on:press="..."></Action>`
- `<ButtonRow align="left|center|right">`
- `<Modal id="..." title="...">`
- `<Form action="/path">`
- `<Input name="..." type="text|password|ascii-art|hidden">`
- `<Submit>`
- `<Splash duration-ms="...">`
- `<SplashAnimation fps="...">`
- `<AsciiAnimation fps="...">`
- `<Column gap="...">`
- `<Row gap="...">`
## Runtime Controls
Global movement:
- `h` / left: previous tab or previous button in a row
- `l` / right: next tab or next button in a row
- `j` / down: move down inside a page or modal
- `k` / up: move up inside a page or modal
- arrow keys work as aliases
- `Enter`: activate current target or enter the active page
- `Esc`: leave page/edit mode or close a modal
- `Backspace`: go back
- `gg`: jump to the first top-level section
- `G`: jump to the last top-level section
- `Ctrl+D` / `Ctrl+U`: half-page movement
- `?`: shortcuts/help
Chat runtime controls:
- conversation list `j`/`k`, `l` to open, `r` refresh
- conversations open in normal mode
- normal-mode `i` enters insert mode
- insert-mode Enter sends, Esc returns to normal mode
- insert-mode Ctrl-A/Ctrl-E moves start/end
- insert-mode Ctrl-B/Ctrl-F moves by character
- insert-mode Alt-B/Alt-F moves by word
- insert-mode Ctrl-W/Ctrl-H deletes previous word/character
- insert-mode Ctrl-D/Ctrl-K/Ctrl-U deletes next char/to end/full line
- normal-mode `j`/`k` moves lines
- normal-mode Ctrl-N/Ctrl-P moves messages
- normal-mode `g`/`gg`/`G` jumps first/latest message
- normal-mode `,mra` marks all loaded conversations read from the conversation list or an open conversation when provided
- normal-mode `l` on `<<<X Files>>>` opens the file picker
## Commands
Install:
```bash
curl -fsSL https://raw.githubusercontent.com/ryangerardwilson/erza/main/app/install.sh | bash
```
CLI:
```bash
python app/main.py -h
python app/main.py -v
python app/main.py run <source> [--backend <path>] [-u <username> -p <password>]
```
`source` may be:
- a single `.erza` file
- a directory, in which case `erza` resolves `index.erza`
- an `http(s)` URL
- a bare domain like `erza.ryangerardwilson.com`
- omitted, in which case the current directory is used
Examples:
```bash
python app/main.py run erza.ryangerardwilson.com
python app/main.py run app/examples/docs
python app/main.py run app/examples/forms
python app/main.py run app/examples/animation
python app/main.py run app/examples/tasks/app.erza
python app/main.py run app/examples/greetings
python app/main.py run koinonia
python app/main.py run koinonia-singapore.onrender.com -u ryan -p secret
```
## Common Mistakes
Avoid these.
- Do not spread one app across many `.erza` pages unless there is a real need.
- Do not put `<Form>` directly inside a page section.
- Do not mix read-only context and form editing in the same modal.
- Do not treat `ui.open_modal` like a backend route.
- Do not assume `on:press` always means a URL.
- Do not default to mouse-first or arrow-only interaction.
- Do not force backgrounds or fonts.
- Do not model `erza` as a browser-first framework.
- Do not fork chat UI behavior in each app when `erza.chat` can own it.
## Examples Worth Reading
- `app/examples/docs/`: docs-shaped app
- `app/examples/forms/`: local form flow and routes
- `app/examples/animation/`: ASCII animation and splash direction
- `app/examples/tasks/`: task-oriented app flow
- `CHAT_SURFACES_SPEC.md`: reusable chat runtime and Slack adapter contract
- `koinonia/index.erza`: a `268`-line social-media app example that shows how far a single `index.erza` file can go
- `koinonia/`: larger social app using auth, tabs, modals, replies, profile editing, remote deploy, and Supabase-backed state
## Repo Map
- `README.md`: canonical docs source
- `AGENTS.md`: repo guardrails for coding agents
- `PRODUCT_SPEC.md`: current product direction
- `FORMS_SPEC.md`: older form notes
- `CHAT_SURFACES_SPEC.md`: chat runtime and Slack adapter direction
- `app/main.py`: canonical CLI entrypoint
- `app/install.sh`: install and upgrade path
- `app/src/erza/template.py`: template engine
- `app/src/erza/parser.py`: markup compiler
- `app/src/erza/runtime.py`: curses runtime and renderer
- `app/src/erza/chat.py`: reusable chat TUI runtime
- `app/src/erza/backend.py`: backend bridge, routes, sessions
- `app/src/erza/remote.py`: remote fetch, remote forms, remote actions, auth
- `app/examples/`: runnable examples
- `app/tests/`: unit tests
- `koinonia/`: larger end-to-end example app
- `docs_website/`: browser shell for serving this README and terminal docs
## Development
Run the app tests:
```bash
cd app/tests
python -m unittest
```
Run the Koinonia tests:
```bash
cd koinonia
python -m unittest
```
Run the browser docs locally:
```bash
cd docs_website
npm install
npm run dev
```
## Current Status
This repo is intentionally small and still fluid.
What it already proves:
- `.erza` is readable as a TUI authoring language
- single-file app surfaces are practical
- tabbed section navigation works well in the terminal
- modal-only forms simplify write flows
- runtime-local UI actions and backend actions can coexist cleanly
- remote apps can be opened directly by domain
- splash screens and ASCII motion can be first-class
- chat apps can use one shared conversation/message/composer runtime
What is still fluid:
- the final long-term language surface
- the full `erzanet` capability model
- how broad the component vocabulary should become
- how much browser fallback should exist beside true terminal-native hosts