Build the code
Every generated app is an ordinary project you can open and edit. Nothing is hidden behind the platform except hosting, credentials and the model proxy. Here is how one is put together.
The stack
- Frontend
- React, Vite and Tailwind. Your dashboard and your widgets.
- Backend
- Python and FastAPI with the Claritty SDK. Your agents, tools, workflows and triggers.
- Data
- PostgreSQL, provisioned and managed for you.
- The manifest
intelligence.yamlat the root. Everything the app is allowed to do is declared there.
Project layout
intelligence.yaml # what the app can do. The source of truth.
backend/
agents/ # the parts that decide, one prompt per agent
tools/ # the parts that act. Deterministic, no model call
triggers/ # what starts a workflow
routes/app.py # your API, including the required GET /api/widget
models.py # your tables. Every row belongs to a user
main.py
frontend/
src/components/ # your Widget lives here
src/pages/ # your dashboard
src/theme.css # colours and fontTools act, agents decide
This is the line that matters most, and the one people get wrong. A tool is a callable action with no model call in it: read a table, send an email, write a row. An agent is a prompt that chooses which tools to call. Business logic belongs in a tool, where it is deterministic and testable, not in an agent’s prompt where it is re-decided on every run.
tools:
- id: app.list_overdue
handler: backend.tools.list_overdue:run
output:
invoices: { type: array, required: true }
agents:
- id: chaser
model: claude-sonnet-4-6
tools: [app.list_overdue, gmail.send]
promptFile: backend/agents/chaser.md
workflows:
- id: chase
steps:
- id: run
agent: chasertools. This is not advisory. A prompt that talks about an unlisted tool produces an agent that cannot call it, and the manifest is rejected if the tool does not exist at all.The widget endpoint
The dashboard polls one endpoint for widget data, roughly every 30 seconds. Every app has to provide it:
{
"title": "Today",
"value": "12 new",
"items": [ ... ]
}Return whatever your widget renders. Keep the shape and your Widget.tsx in step with each other. An empty widget on the dashboard is almost always this endpoint returning something the component did not expect.
Calling the model
Agents reach the model through the SDK, never a raw provider key. The proxy handles routing and billing.
from claritty_sdk.llm import get_llm_client
client = get_llm_client("claude-sonnet-4-6")
result = client.chat(
[{"role": "user", "content": "Summarise today's mentions"}],
system="You are terse. Answer in one sentence.",
)
print(result.content, result.usage.total_tokens)get_llm_client(model) | The model id is required. The proxy routes to the right provider from it. |
chat(messages, …) | Takes a list of message dicts, not a string. Returns a ChatResult with content, finish_reason and usage. |
chat_with_tools(…) | Same, for a tool-calling loop. |
chat_stream(…) | Same, streamed. |
Running locally
You do not need a provider key. With no token set, agents use their fallback, so a fresh clone runs with no configuration. The only thing it needs is DATABASE_URL, which Docker Compose provides.
For real model calls while developing, run claritty login and the app is wired to the managed proxy. Still no provider key on your side.
One app, many users
Apps are multi-tenant. Every row belongs to a user, and every request carries an X-User-ID header.
# Wrong. Returns every user's rows.
invoices = session.query(Invoice).all()
# Right.
invoices = session.query(Invoice).filter(Invoice.user_id == user_id).all()Verifying before you ship
claritty test # runs the app for real, locally
claritty deploy --dry-run # runs the gates, changes nothing