# Connectors and MCP
Source: https://docs.cycls.com/agents/connectors
A user connects an account once, and the tools that need it get a live credential. OAuth, pasted keys, private endpoints and remote MCP servers.
A connector is a grant a person makes, for themselves or for a workspace, plus
the policy over the tools that use it. You declare it once, the directory renders
it, and the credential is attached server-side when a tool runs. Your code never
holds a token and neither does the browser.
```python catalog.py theme={null}
import cycls
notion = cycls.OAuth2(
"notion",
authorize="https://api.notion.com/v1/oauth/authorize",
token="https://api.notion.com/v1/oauth/token",
client_id=cycls.env("NOTION_CLIENT_ID"),
secret=cycls.env("NOTION_CLIENT_SECRET"),
scopes=["read_content", "update_content"],
api="https://api.notion.com",
api_headers={"Notion-Version": "2022-06-28"},
scope="either",
)
web = cycls.Web().auth(cycls.Clerk()).connectors(notion)
llm = cycls.LLM().model("anthropic/claude-sonnet-4-6").connectors(notion)
```
Declare the same objects on both builders. `Web` serves the directory and the
connect routes, `LLM` decides whose tools the loop may offer.
## Three kinds of grant
Authorization code with PKCE against an app you registered, or against an MCP
server that publishes its own authorization server and registers clients
dynamically.
```python theme={null}
github = cycls.OAuth2(
"github",
authorize="https://github.com/login/oauth/authorize",
token="https://github.com/login/oauth/access_token",
client_id=cycls.env("GITHUB_CLIENT_ID"),
secret=cycls.env("GITHUB_CLIENT_SECRET"),
scopes=["repo", "read:org"],
extra={"prompt": "consent"},
scope="user",
)
```
Tokens refresh when stale. The state that rides through the provider is signed
and names the user and workspace, so the callback carries no JWT.
For services where the user pastes a key.
```python theme={null}
posthog = cycls.Key(
"posthog",
hint="phx_...",
api="https://app.posthog.com",
auth="bearer",
scope="workspace",
)
```
`hint` is the field placeholder. Override `validate(value)` on a subclass to
reject a malformed key before it is stored.
For services where the secret is the address, such as a private MCP link a
merchant copies from their dashboard.
```python theme={null}
zid = cycls.Endpoint("zid", host=".zid.sa", scope="workspace")
```
`host` bounds what counts as a valid link, so a typo or an internal address is
refused rather than dialled.
## Where a grant lives
```python theme={null}
cycls.OAuth2("gmail", ..., scope="user") # personal, follows the person
cycls.Key("posthog", ..., scope="workspace") # shared with the team
cycls.OAuth2("drive", ..., scope="either") # the person chooses
```
A mailbox is a person, so `user`. A team analytics key is shared, so `workspace`.
`either` lets an admin connect one account for everyone while individuals can
still link their own.
## Calling a REST API
Give a connector an `api` base and the loop offers one `{name}_request` tool. The
model builds a path, the platform attaches the credential, and the host is bounded
to that base.
```python theme={null}
linear = cycls.Key("linear", api="https://api.linear.app", auth="bearer")
```
The same relay backs `cycls.connector(name)` inside a generated app, so a
dashboard an agent builds can read live data without ever holding a key.
| Argument | Meaning |
| ------------- | ---------------------------------------------- |
| `api` | https base URL the relay is bounded to |
| `api_headers` | fixed headers the API requires on every call |
| `auth` | `bearer`, `basic`, `header` or `query` |
| `auth_name` | header or query parameter name when not bearer |
## MCP servers
`cycls.MCP` connects to a remote MCP server. The harness speaks Streamable HTTP
itself, so every provider gets MCP, not just Anthropic. Tools are discovered once
and cached, and a session opens only when a tool is actually called.
```python theme={null}
server = (
cycls.MCP("https://mcp.example.com/mcp")
.name("example")
.token(cycls.env("EXAMPLE_TOKEN"))
.allow("search", "create_issue")
)
llm = cycls.LLM().mcp(server)
```
| Method | Meaning |
| ------------------- | ------------------------------------------------------------------------- |
| `.name(alias)` | the name shown in the UI and used in the tool index |
| `.token(bearer)` | a fixed bearer token |
| `.connector(oauth)` | act with a user's grant instead of a fixed token |
| `.allow(*names)` | offer only these tools |
| `.guidance(text)` | extra prompt guidance for this server |
| `.writes(fn)` | classify which calls change something, for approvals |
| `.server_side()` | hand the server to the Anthropic connector instead, Anthropic models only |
## Loading tools on demand
A directory of connectors can hold hundreds of tools, and their schemas would not
fit in a system prompt. Each connector contributes one index line of about
fifteen tokens instead, and the model calls `find_tools` to load the schemas it
needs. Loaded tools stay for the rest of the conversation.
## Approvals
Every tool call is classified before it runs.
| Risk | Behavior |
| ----------- | ---------------------------------- |
| read | always runs |
| write | follows the composer's Auto switch |
| destructive | asks in both modes |
When a call needs approval, the model gets a stop and the user gets a confirm
card naming the tool and the arguments. Approving binds to those exact arguments,
so a changed call asks again. Each person can also set allow, ask or never per
tool, and an organization admin can switch a connector off for everyone.
Deletes through bash are not destructive by default, because the sandbox shims
`rm` into a thirty day trash. Commands with nothing behind them, such as `shred`,
`mkfs` or `git push --force`, always ask.
## Directory copy
What the directory shows can come from your code or from a CMS, field by field,
with code winning.
```python theme={null}
web = cycls.Web().cms(
brand="https://cms.example.com/agents/my-agent",
connectors="https://cms.example.com/connectors",
token=os.environ["CMS_TOKEN"],
)
```
This keeps behavior in Python, where it belongs, and copy in a CMS where a writer
can change it without a redeploy.
Connector objects are built on your machine and pickled into the deployment. Keep
the declaration file free of functions and module-level values a callable would
close over: a `writes` classifier or a tool handler must live in the file you
deploy, not in an imported module.
## Next
What isolates the bash tool, and what it does not cover.
# Context
Source: https://docs.cycls.com/agents/context
Every turn hands your agent a context object with the conversation, the user, the workspace and the switches that user set.
The single argument to an agent body is `context`. It is the whole per-request
input, and `llm.run(context=context)` reads everything it needs from it.
```python theme={null}
@cycls.agent(web=web, volumes={"/workspace": chats})
async def my_agent(context):
print(context.last_message)
print(context.user.id, context.user.plan)
async for ev in llm.run(context=context):
yield ev
```
## Fields
| Field | Type | Meaning |
| ------------------------ | ---------------- | -------------------------------------------------------------------- |
| `context.messages` | `Messages` | the conversation as `{"role", "content"}` dicts |
| `context.messages.raw` | `list` | the same turns with every part, including components and attachments |
| `context.last_message` | `str` | the text of the most recent message |
| `context.user` | `User` or `None` | the authenticated user when auth is configured |
| `context.chat_id` | `str` | the current chat |
| `context.workspace` | `Workspace` | this user's storage scope |
| `context.workspace_id` | `str` or `None` | the active workspace in multi-workspace mode |
| `context.prod` | `bool` | `True` under `deploy`, `False` under `run` |
| `context.disabled_tools` | `list` | tools this user switched off in Settings |
| `context.connectors` | `list` | connectors the user mentioned with `@` |
| `context.approvals` | `list` | approvals granted from a confirm card, this turn only |
| `context.auto` | `bool` | the composer's automatic approval switch |
## Messages
`context.messages` reads as plain text, which is what most code wants.
```python theme={null}
[{"role": "user", "content": "Summarize this"},
{"role": "assistant", "content": "Here is the summary"}]
```
`context.messages.raw` keeps everything: component parts, tool steps and
attachment references. Reach for it when you need to inspect what actually
happened rather than what was said.
## The user
```python theme={null}
user = context.user
user.id # stable user id
user.org_id # organization id, when the user is acting in one
user.org_slug
user.org_role
user.org_permissions
user.plan # subscription tier from the JWT claim
user.features # feature flags
```
`context.user` is `None` when no auth provider is configured. See
[Authentication](/web/auth) for how the claims are populated.
## The workspace
`context.workspace` is this user's scope on the `/workspace` volume, and it is
what `cycls.DB` writes to.
```python theme={null}
from datetime import datetime, timezone
db = cycls.DB(context.workspace)
month = datetime.now(timezone.utc).strftime("%Y-%m")
entry = await db.get(f"usage/{month}", {"count": 0})
entry["count"] += 1
await db.put(f"usage/{month}", entry)
```
Files the user sees in the files panel live under `context.workspace.root`.
## Gating on environment
`context.prod` separates a local run from a deployment, which is the clean way to
keep billing and quota checks out of your development loop.
```python theme={null}
exempt = not context.prod
if context.user.plan == "o:free_org" and not exempt:
yield {"type": "callout", "callout": "This workspace needs a paid plan.", "style": "error"}
yield {"type": "ui", "action": "open_plan_modal"}
return
```
## Respecting user switches
`context.disabled_tools` holds the tools this person turned off. `llm.run()`
already honors it. You only need to read it if you are running your own loop or
want to explain the difference to the user.
```python theme={null}
if "WebSearch" in context.disabled_tools:
yield "_Web search is off in your settings, so this answer is from memory._\n\n"
```
A switch can never enable a tool that `allowed_tools` did not grant.
## Logging with attribution
`cycls.log` writes one structured line that the log backend captures, with user
and chat attribution attached.
```python theme={null}
cycls.log("cap_hit", user=context.user, chat_id=context.chat_id,
kind="user_free_monthly", count=entry["count"])
```
Query it later with `cycls logs -q` or `cycls sql`. See
[Observability](/ship/observability).
## Next
Workspace instructions and skills the model loads on demand.
# Custom loops
Source: https://docs.cycls.com/agents/custom-loop
Hook the default loop with a few lines, or replace it entirely and keep the session, provider, tools and compaction.
Most agents never need this page. The default loop handles streaming, tools,
retries, compaction and persistence. Reach for a custom loop when your product
needs orchestration the loop does not model, such as routing between models,
running a planning pass, or enforcing a state machine.
## Hooking without replacing
Events are dicts, so you can observe and react while still passing everything
through.
```python theme={null}
async for ev in llm.run(context=context):
if isinstance(ev, dict):
if ev.get("type") == "step" and ev.get("tool_name") == "web_search":
metrics.increment("search")
if ev.get("type") == "callout" and ev.get("style") == "error":
alert_ops(ev["callout"])
yield ev
```
You can also transform or drop events, or inject your own around them.
```python theme={null}
yield {"type": "status", "status": "Working"}
async for ev in llm.run(context=context):
yield ev
yield {"type": "ui", "action": "suggest", "text": "Turn this into a report"}
```
## Replacing the loop
```python theme={null}
from cycls._agent.harness import (
default_loop, make_provider, Session, build_tools, dispatch, compact, events,
)
async def my_loop(*, context, system="", tools=None, allowed_tools=(), model=None, **kw):
provider = make_provider(model)
session = await Session.open(context)
await session.add_user(context.messages.raw[-1].get("content", ""))
tool_list = build_tools(allowed_tools, tools or [], vendor=model.split("/", 1)[0])
async for ev in provider.stream(
messages=session.context(),
system=system,
tools=tool_list,
max_tokens=kw.get("max_tokens") or 8192,
):
yield ev
await session.checkpoint()
llm = cycls.LLM().model("anthropic/claude-sonnet-4-6").loop(my_loop)
```
`fn` is an async generator that yields events. Accept `**kw` so new keyword
arguments do not break your loop as the SDK grows.
## The kit
| Piece | What it does |
| ---------------------------------------------------- | ---------------------------------------------------------------------- |
| `default_loop` | the built-in loop, callable directly if you want to wrap it |
| `make_provider(model, ...)` | one streaming interface over Anthropic and OpenAI-compatible endpoints |
| `Session` | the message log and its persistence |
| `build_tools(allowed, custom, vendor=, web_search=)` | the provider-neutral tool list |
| `dispatch(block, workspace, timeout, handlers, ...)` | run one tool call, returns a step event and a coroutine |
| `compact(provider, messages, keep, max_tokens)` | summarize all but the most recent `keep` tokens |
| `events` | dict factories for `text`, `thinking`, `step`, `callout`, `tool_call` |
| `ToolContext` | who a tool acts for: user, workspace, chat, approvals |
## Session
`Session` is the part worth understanding, because it is what makes a run
survivable.
```python theme={null}
session = await Session.open(context)
await session.add_user(content) # checkpoints the user turn before any model call
session.messages # the working message list
session.context() # the window the provider should see
await session.checkpoint() # persist what has happened so far
session.rollback() # drop the assistant tail after a failure
await session.clear(keep, enough) # stub old tool results, False if that frees under enough
await session.compact(provider) # summarize older turns
```
`add_user` writes to disk before the model is called, so a dropped connection
never loses what the person said. `rollback` removes only the assistant tail, so
a failed turn does not corrupt the transcript. `clear` and `compact` change only
what `context()` returns. The turns on disk stay as they were.
## The provider interface
```python theme={null}
class Provider(Protocol):
model: str
def stream(self, *, messages, system, tools, max_tokens,
mcp_servers=None, thinking=None) -> AsyncIterator: ...
async def complete(self, *, messages, system, max_tokens) -> str: ...
```
`stream` yields UI events, bare strings for text deltas, then exactly one `Turn`
carrying the assistant content, stop reason and token counts. Adding a new
provider means one file that conforms to this protocol.
The message shape is Anthropic's, which is the richest superset across vendors.
Each provider translates from it to its own wire format.
## What you give up
Writing your own loop means owning the behavior the default one provides:
retries with backoff, clearing and compaction before overflow, one recovery
replay on a context error, checkpointing between tool batches, heartbeats during
long tool runs, cost logging, and unwinding cleanly on cancellation.
Start by wrapping `default_loop` and intercepting what you need, then replace it
only when wrapping stops being enough.
## Next
Branding, themes, the explore menu and the example gallery.
# Files and the canvas
Source: https://docs.cycls.com/agents/files
Every user gets a workspace of files. The canvas renders spreadsheets, documents, slides, PDFs, images and HTML without leaving the chat.
Each user of an agent gets a file tree on the volume mounted at `/workspace`. The
`Editor` and `Bash` tools read and write it, the files panel lists it, and the
canvas renders one file at a time beside the conversation.
## Where files live
```
/workspace//
AGENT.md instructions, opened from the dock, not listed in Files
report.md files the user keeps
media/ images and videos added to report.md in the editor
data/sales.csv
apps/burnup/ apps the agent built
skills// skills the user created
.tmp/ scratch, hidden and cleaned up
.db/ chat store, not readable from the sandbox
```
Paths in tools are workspace relative. `~/report.md` and `/workspace/report.md`
both resolve to the same file, traversal outside the workspace is refused, and
`/tmp` raises an explicit error because every bash command gets its own `/tmp`
that no other tool can see.
Scratch belongs in `.tmp/`. It is hidden from the files panel and cleaned up, so
intermediate downloads and working data do not clutter what the user sees.
## Opening a file on the canvas
The `Canvas` tool opens one finished file in the side panel.
```python theme={null}
llm = cycls.LLM().allowed_tools(["Bash", "Editor", "Canvas"])
```
From a custom loop, or from your own code, the same action is a `ui` event:
```python theme={null}
yield {"type": "ui", "action": "open_canvas", "path": "report.xlsx"}
```
## What the canvas renders
| File type | Rendered as | Where the work happens |
| ---------------------------------------------------------------- | ------------------------------------------------------- | ---------------------- |
| `csv`, `tsv`, `xls`, `xlsx`, `xlsm`, `ods` | interactive grid with sheet tabs | in the browser |
| `docx` | formatted document with pages, fonts, tables and images | in the browser |
| `ppt`, `pptx`, `odp` | slide viewer with a thumbnail rail | office render service |
| `doc`, `odt`, `rtf`, `epub` | read-only PDF | office render service |
| `pdf` | PDF viewer | in the browser |
| `md`, `html`, images, audio, video, `glb`, `gltf`, code and text | native viewer | in the browser |
| anything else | download card | |
Any failure, including an unreachable service or an unparseable file, falls back
to the download card rather than an error page.
## Editing on the canvas
Text files open with an **Edit** button. Markdown opens in a rich editor with a
toolbar, a `/` menu for inserting blocks, and drag handles for moving them. It
covers headings, lists, checklists, tables, quotes, code, links and dividers, and
saving writes plain markdown back to the file. Each block takes its direction
from its own text, so Arabic and English paragraphs sit side by side.
Images and videos pasted, dropped or picked into a document are saved in a
`media/` folder beside it and linked by a relative path. The canvas shows those
images and plays those videos in the markdown view too.
Markdown that contains math, raw HTML or footnotes opens in a plain-text editor
instead, so saving cannot drop them. Other text and code files also edit as plain
text. Every other format on the canvas is view-only.
## Configuring office rendering
Spreadsheets and `.docx` render from raw bytes in the browser and need no
service. Presentations and the PDF fallback call a shared render service,
configured with two environment variables:
```bash theme={null}
OFFICE_RENDER_URL=https://office-render.cycls.ai
OFFICE_RENDER_SECRET=your_shared_secret
```
With either unset, those two paths return a download card and everything else
keeps working.
LibreOffice is about 1GB and is used by a small fraction of turns, which is why
it runs in one shared service rather than inside every agent image. Rendered
output is cached under a hidden `.cache/office/` directory in the workspace, so
reopening a deck does not repay the conversion.
## Uploads
Users attach files in the composer. The loop ingests them before the model call:
images and PDFs go to the model as media when `.vision(True)`, and every
attachment is written into the workspace so tools can open it.
```python theme={null}
web = cycls.Web().max_upload(256) # MB per file, default 512
```
For text-only models, set `.vision(False)`. The attachment stays in the
workspace and the model receives a note naming the file, which it can then read
with a tool.
## The HTTP API
Files are also a REST surface, which is what the files panel and mobile clients
use. All routes require auth.
| Method | Path | Purpose |
| -------- | -------------------- | ------------------------------------------------------------ |
| `GET` | `/files?path=subdir` | list a directory |
| `GET` | `/files/{path}` | download, add `?download` for an attachment header |
| `PUT` | `/files/{path}` | upload, the raw body is the file, multipart is also accepted |
| `PATCH` | `/files/{path}` | rename, body `{"to": "new/path"}` |
| `POST` | `/files/{path}` | create a directory |
| `DELETE` | `/files/{path}` | delete, which moves the file to trash |
Deleting moves a file to trash and keeps it restorable for thirty days through
`GET /trash`, `POST /trash/{id}/restore` and `DELETE /trash/{id}`.
## Sharing
```
POST /share create a share link for a chat
GET /share list share links
DELETE /share/{token} revoke
GET /shared/{user}/{token} the public page
POST /shared/{user}/{token}/fork copy the conversation into the viewer's workspace
```
A shared chat carries the files it references, so a recipient can open the
artifact without an account. Forking lands in the forker's active workspace.
## Next
Let a user connect an account, then give tools the credential.
# Instructions and skills
Source: https://docs.cycls.com/agents/knowledge
Give an agent standing instructions it reads every turn, and skill packs it loads only when the task calls for them.
Two mechanisms sit between your system prompt and the model. Instructions are
always in context. Skills are loaded on demand.
## Workspace instructions
Every turn, the loop reads `AGENT.md` from the user's workspace root and appends
it to the system prompt, fenced as user preferences that stay subordinate to your
`.system()` prompt.
```markdown AGENT.md theme={null}
Always answer in Arabic unless I write in English.
Our fiscal year starts in April.
When I ask for a report, use the template in templates/report.md.
```
In the web client, users open **Instructions** from the dock at the bottom of the
right panel. The file opens in the canvas editor, and an empty one shows a few
examples to start from. `AGENT.md` does not appear in the Files list. Users can
also ask the agent to change it.
Nothing is needed on your side, but the file name is configurable:
```python theme={null}
llm = cycls.LLM().instructions("NOTES.md") # a different name
llm = cycls.LLM().instructions(None) # disable the mechanism
```
The Instructions tile always opens `AGENT.md`, so keep the default name if your
users edit their instructions in the web client.
Content is capped at 24KB and truncated beyond that. Binary or unreadable files
are ignored.
## Skills
A skill is a folder of instructions the model pulls in when it decides the task
calls for it. Only the name and description sit in the system prompt, which costs
a line or two. The body enters context when the model calls the `skill` tool.
```
skills/
pdf-reports/
SKILL.md
scripts/render.py
templates/invoice.html
```
```markdown SKILL.md theme={null}
---
name: pdf-reports
description: Generate branded PDF reports from CSV data. Use when the user asks for a PDF report, an invoice, or a printable summary.
---
# PDF reports
Run `python /skills/pdf-reports/scripts/render.py `.
The template lives beside the script and expects columns: date, item, amount.
```
The frontmatter does the work. `name` is lowercase with hyphens and falls back to
the folder name. `description` is what the model matches against, so write it as
"what this does, and when to use it" in at most 1KB. The body can be up to 48KB.
### Shipping skills with the agent
```python theme={null}
image = cycls.Image().copy("skills/")
llm = cycls.LLM().skills("skills")
```
Shipped skills are read-only and mount at `/skills//` inside the bash
sandbox, so scripts find their own templates by absolute path. They version with
your deploys.
### Skills users create
Any `skills//SKILL.md` in a user's workspace joins the catalog
automatically, rescanned about every thirty seconds. A user skill wins a name
collision with a shipped one, so someone can override your default without a
redeploy.
```python theme={null}
llm = cycls.LLM().skills(None) # turn the whole mechanism off
```
The description decides whether the model reaches for the skill, so write the
trigger rather than the summary. "Use when the user asks for a PDF report, an
invoice, or a printable summary" works better than "PDF utilities".
## Which to use
| Situation | Use |
| --------------------------------------------------- | ----------- |
| A preference that applies to every turn | `AGENT.md` |
| A procedure with support files, needed occasionally | a skill |
| Product behavior you own and never want overridden | `.system()` |
## Next
The workspace file tree, and how the canvas renders it.
# Models
Source: https://docs.cycls.com/agents/models
One builder for every provider: Anthropic natively, and any OpenAI-compatible endpoint through a base URL.
`cycls.LLM` holds everything about how an agent runs. It is immutable, so each
method returns a new builder and one base config can be branched safely.
```python theme={null}
llm = (
cycls.LLM()
.model("anthropic/claude-sonnet-4-6")
.system("You are a helpful assistant.")
.max_tokens(16_384)
.context(200_000)
.thinking("medium")
.price(input=3, output=15, cache_read=0.30, cache_write=6)
)
async for ev in llm.run(context=context):
yield ev
```
## Choosing a model
Model strings are always `vendor/model`. Anything that is not `anthropic/` goes
through the Chat Completions adapter, so `base_url` points it at the endpoint and
`api_key` supplies the key.
```python theme={null}
# Anthropic, native API. Reads ANTHROPIC_API_KEY
cycls.LLM().model("anthropic/claude-sonnet-4-6")
# OpenAI. Reads OPENAI_API_KEY
cycls.LLM().model("openai/gpt-5.4")
# Open weight models on their vendor endpoints
cycls.LLM().model("deepseek/deepseek-flash").base_url("https://api.deepseek.com/v1").api_key(os.environ["DEEPSEEK_API_KEY"])
cycls.LLM().model("moonshotai/kimi-k3").base_url("https://api.moonshot.ai/v1").api_key(os.environ["MOONSHOT_API_KEY"])
cycls.LLM().model("zai/glm-5.3").base_url("https://open.bigmodel.cn/api/paas/v4").api_key(os.environ["ZAI_API_KEY"])
# Your own inference server
cycls.LLM().model("local/kimi-k3").base_url("http://localhost:8000/v1").api_key("unused")
```
| Vendor prefix | Endpoint | Notes |
| -------------------------------------------------------------- | -------------------------------------- | ----------------------------------------------------------------------------- |
| `anthropic` | built in | native path: cache breakpoints, extended thinking, server-side search and MCP |
| `openai` | built in | reads `OPENAI_API_KEY` |
| `deepseek` | `https://api.deepseek.com/v1` | open weights, thinking toggle plus effort |
| `moonshotai`, `kimi`, `moonshot` | `https://api.moonshot.ai/v1` | open weights, effort tiers low, high, max |
| `zai`, `zhipu`, `glm` | `https://open.bigmodel.cn/api/paas/v4` | open weights, thinking is a binary toggle |
| `qwen`, `dashscope` | vendor endpoint | open weights, thinking budget in tokens |
| `groq`, `xai`, `mistral`, `google`, `perplexity`, `openrouter` | vendor endpoint | standard `reasoning_effort` |
| `local`, `vllm`, `modal`, anything else | your URL | vLLM, SGLang, Ollama or a private gateway |
The prefix selects the reasoning dialect, so use the vendor prefix that matches
the API you are calling, even when you self-host that model. See
[Self-hosted and open models](/guides/self-hosted-models) for serving your own.
Model identifiers change as vendors ship new versions. Check the vendor's
documentation for the current name before pinning one in production.
Keys come from the environment (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`) or from
`.api_key()`. Use `.headers()` for endpoints that authenticate outside the bearer
token, such as a Modal proxy or Cloudflare Access.
```python theme={null}
llm = (
cycls.LLM()
.model("modal/moonshotai/Kimi-K3")
.base_url("https://your-workspace--kimi-k3.modal.direct/v1")
.api_key("unused")
.headers({"Modal-Key": os.environ["MODAL_TOKEN_ID"],
"Modal-Secret": os.environ["MODAL_TOKEN_SECRET"]})
)
```
## Reasoning
`.thinking()` is one unified control, translated into each vendor's dialect.
```python theme={null}
cycls.LLM().thinking("adaptive") # default, the model decides
cycls.LLM().thinking("low") # or "medium", "high"
cycls.LLM().thinking(None) # off where the provider allows it
```
| Vendor prefix | Wire format |
| ------------------------------------------------------------------------------------- | ----------------------------------------------------- |
| `openai`, `azure`, `google`, `gemini`, `xai`, `grok`, `mistral`, `groq`, `perplexity` | `reasoning_effort: low \| medium \| high` |
| `zai`, `zhipu`, `glm` | `thinking: {type: enabled \| disabled}` |
| `deepseek` | the GLM toggle plus `reasoning_effort` |
| `qwen`, `dashscope`, `alibaba` | `enable_thinking` plus a token `thinking_budget` |
| `kimi`, `moonshot`, `moonshotai` | `reasoning_effort: low \| high \| max` |
| `openrouter` | `reasoning: {effort}` |
| `anthropic` | extended thinking, auto-disabled on models without it |
A vendor with no mapping, such as a host prefix like `modal` or `vllm`, gets no
reasoning parameter and prints one warning. Use `.extra_body()` for those.
```python theme={null}
llm = cycls.LLM().extra_body({"reasoning_effort": "high", "top_p": 0.9})
```
`.extra_body()` merges after the built-in mapping, so your keys win. It is the
escape hatch for any parameter Cycls does not model.
## Budgets and cost
```python theme={null}
llm = (
cycls.LLM()
.max_tokens(16_384) # output tokens per request, default 8k
.context(200_000) # model context window, default 1M
.price(input=3, output=15, cache_read=0.30, cache_write=6)
)
```
`.context()` is what decides when compaction starts, so set it to match the model
you actually run. `.price()` takes USD per million tokens. With prices set, every
turn logs its cost and `cycls cost` and `cycls sql` can slice spend by user, chat
or model. Without it, costs report as zero.
## Long conversations
The loop keeps a chat inside the context window without ending it. Before each
model call, and once more when a run ends, it compares the last request's input
tokens with a trigger.
| | Value |
| ------------- | ------------------------------------------------------------------------------------------ |
| Trigger | 70% of `.context()`, or `.context()` minus `.max_tokens()` minus 30,000 when that is lower |
| Kept verbatim | the most recent 30% of the window |
Past the trigger, the loop frees room in two steps:
1. **Clear.** Old tool results, and tool arguments longer than 1,000 characters,
are replaced with a short placeholder. Paths and commands stay, so the model
still knows what it did. A loaded skill is never cleared. This makes no model
call.
2. **Summarize.** When clearing would free less than 25% of the window, the model
summarizes everything before the recent 30% instead. The request reuses the
loop's system prompt and tools, so the provider serves most of it from cache.
The user sees "Summarizing earlier messages to keep this chat going..." while
it runs.
A chat that resumes after 20 minutes idle is cleared before its first call when
that frees 10% of the window. The provider's cache has mostly expired by then, so
clearing costs little in cache hits.
The system prompt, including `AGENT.md` and the skill catalog, is never cleared or
summarized. The saved transcript is never rewritten either, so users always see
the whole conversation. If a summary fails or takes longer than five minutes, the
model stops seeing the older turns, gets a line saying they were dropped, and the
chat continues.
When the provider still reports that the context is full, the loop summarizes and
replays the turn, once per run. One `read` returns at most 50,000 characters, so a
single large file or attachment cannot fill the window by itself.
## Vision and text-only models
Attachments are sent as base64 media by default. Text-only models reject that, so
turn vision off and the file stays in the workspace with a note naming it, which
the model can then open with a tool.
```python theme={null}
llm = cycls.LLM().model("zai/glm-5.3").base_url("https://open.bigmodel.cn/api/paas/v4").vision(False)
```
## Web search
```python theme={null}
llm = cycls.LLM().allowed_tools(["WebSearch"]).web_search("brave") # default
llm = cycls.LLM().allowed_tools(["WebSearch"]).web_search("native") # Anthropic server-side
```
`brave` is the portable search and fetch pair. It works on any model and needs
`BRAVE_API_KEY`. Without that key it falls back to the provider's native search
where one exists. `native` forces the provider's server-side search, which today
means Anthropic only.
## Full builder reference
| Method | Purpose |
| ---------------------------------------------------- | ---------------------------------------------------------------- |
| `.model(str)` | `vendor/model` string. Required |
| `.system(str)` | system prompt |
| `.tools(list)` | [custom tool schemas](/agents/tools#custom-tools) |
| `.on(name, fn, label=, icon=, details=)` | handler for a custom tool |
| `.allowed_tools(names)` | enable [built-in tools](/agents/tools) |
| `.instructions(path)` | workspace instructions file, default `AGENT.md`, `None` disables |
| `.skills(*dirs)` | [ship skills](/agents/knowledge#skills) with the agent |
| `.context(n)` | context window in tokens, default 1M |
| `.max_tokens(n)` | output tokens per request, default 8k |
| `.price(input=, output=, cache_read=, cache_write=)` | USD per million tokens |
| `.thinking(spec)` | `"adaptive"`, `"low"`, `"medium"`, `"high"` or `None` |
| `.extra_body(params)` | vendor-specific request extras |
| `.vision(bool)` | accept base64 media, default `True` |
| `.web_search(mode)` | `"brave"` or `"native"` |
| `.mcp(*servers)` | [remote MCP servers](/agents/connectors#mcp-servers) |
| `.connectors(*objs)` | [connector tools](/agents/connectors) |
| `.bash_timeout(secs)` | bash sandbox timeout, default 600 |
| `.sandbox(network=False)` | cut bash off from the network |
| `.base_url(url)` | custom endpoint |
| `.api_key(key)` | override the environment key |
| `.headers(mapping)` | extra HTTP headers on every model request |
| `.loop(fn)` | [replace the loop](/agents/custom-loop) |
| `.run(context=, client=)` | execute, yielding events |
## Next steps
Built-in tools, custom handlers and approvals.
Point an agent at vLLM, SGLang or a private endpoint.
# Agents
Source: https://docs.cycls.com/agents/overview
A chat product in one decorator: managed LLM loop, tools, files, history and a web interface.
An agent is an async generator that receives a `context` and yields events. Cycls
wraps it in a web application, a streaming protocol, per-user storage and a
managed model loop.
```python agent.py theme={null}
import cycls
llm = (
cycls.LLM()
.model("anthropic/claude-sonnet-4-6")
.system("You are a helpful assistant.")
.allowed_tools(["Bash", "Editor", "WebSearch"])
)
@cycls.agent(
image=cycls.Image().copy(".providers.env", ".env"),
web=cycls.Web().auth(cycls.Clerk()).title("My Agent"),
volumes={"/workspace": cycls.Volume("my-agent")},
)
async def my_agent(context):
async for ev in llm.run(context=context):
yield ev
```
```bash theme={null}
cycls run my_agent.py # localhost:8080
cycls deploy my_agent.py # https://my-agent.cycls.ai
```
## The decorator
```python theme={null}
@cycls.agent(name=None, image=None, web=None, volumes=None, memory="1Gi")
```
| Argument | Meaning |
| --------- | --------------------------------------------------------------------------------- |
| `name` | deployment name and subdomain. Defaults to the function name |
| `image` | [`cycls.Image`](/build/images) for packages and bundled files |
| `web` | [`cycls.Web`](/web/interface) for interface, auth and branding |
| `volumes` | mount paths to [`cycls.Volume`](/build/volumes). A `/workspace` entry is required |
| `memory` | container memory, for example `"1Gi"`, `"4Gi"` |
Agents keep chats, files and credentials under `/workspace`, so the decorator
raises if no volume is mounted there. Locally, `cycls run` ignores volumes and
your code sees the local filesystem.
## What the managed loop does
`llm.run(context=context)` is the default loop. Yielding its events through your
body is all a normal agent needs.
Text, reasoning and tool-call deltas from Anthropic natively and from any
OpenAI-compatible endpoint.
Built-in tools and your handlers, run in parallel where the provider sends a
batch, with results fed back to the model.
Transient provider failures are retried with backoff. Context overflow
triggers one compaction and a replay.
At 70% of the window, old tool results are cleared, and older turns are
summarized when that is not enough. The saved transcript stays whole.
The user's turn is written to disk before the model is called, so a dropped
connection never loses it.
Every turn logs tokens and cost when `.price()` is set, queryable with
`cycls cost` and `cycls sql`.
## A turn, end to end
```mermaid theme={null}
sequenceDiagram
participant U as Browser
participant A as Agent body
participant L as Managed loop
participant M as Model
participant T as Tools
U->>A: POST / with messages
A->>L: llm.run(context=context)
L->>L: load session, ingest attachments, build system prompt
L->>M: stream request
M-->>L: thinking, text, tool calls
L->>T: run tool batch
T-->>L: results
L->>M: continue with results
M-->>L: final text
L-->>A: events
A-->>U: SSE stream
```
## Yielding your own events
The body is ordinary Python, so you can emit anything before, during or after the
loop. Events are plain dicts, and strings are markdown text.
```python theme={null}
@cycls.agent(web=web, volumes={"/workspace": chats})
async def my_agent(context):
if context.user.plan == "u:free_user" and await over_quota(context):
yield {"type": "callout", "callout": "Free limit reached.", "style": "warning"}
yield {"type": "ui", "action": "open_plan_modal"}
return
yield {"type": "status", "status": "Thinking about it"}
async for ev in llm.run(context=context):
yield ev
yield "\n\n_Answered by My Agent._"
```
See [Streaming components](/agents/streaming) for every event type.
## Watching the loop
Events are dicts, so a plain check is enough to react without changing behavior.
```python theme={null}
async for ev in llm.run(context=context):
if isinstance(ev, dict) and ev.get("type") == "callout" and ev.get("style") == "error":
alert_ops(ev["callout"])
yield ev
```
`cycls.to_ui(ev)` still appears in older examples. It is now an identity function
kept for backwards compatibility, so `yield ev` and `yield cycls.to_ui(ev)` do
the same thing.
## Adding HTTP routes
An agent is also a web service. Use `.server` for webhooks, health checks and
OAuth callbacks, and `Depends(my_agent.auth)` to protect a route with the same
identity the chat endpoint uses.
```python theme={null}
from fastapi import Depends
@my_agent.server.api_route("/webhook", methods=["POST"])
async def webhook(request):
payload = await request.json()
return {"ok": True}
@my_agent.server.api_route("/profile", methods=["GET"])
async def profile(user=Depends(my_agent.auth)):
return {"id": user.id, "plan": user.plan}
```
## Next steps
Providers, reasoning, budgets and pricing.
Built-in tools and your own handlers.
Messages, users, workspaces and per-request switches.
Replace the default loop while keeping the kit.
# Sandbox and trust model
Source: https://docs.cycls.com/agents/sandbox
What isolates the bash tool from your secrets, what that isolation covers, what it does not, and the settings that close the remaining gaps.
This page is for developers deploying an agent that runs shell commands on behalf
of untrusted users. It states the trust boundaries, what is verified, and what is
explicitly not covered.
## Trust inputs
| Party | Trust | Reasoning |
| -------------------------- | --------- | ------------------------------------------------------------------ |
| Agent developer | trusted | you configured the image, wrote the handlers and own the workspace |
| End user and their prompts | untrusted | prompt injection is the primary threat |
Every control below is designed against prompt injection, not against a malicious
developer.
## The layers
```
host → Docker container (agent runtime) → bubblewrap sandbox (bash tool)
```
The agent's Python process holds secrets as environment variables: provider keys,
your own `.env` contents, and any credential your handlers use. The purpose of
the bubblewrap layer is to keep a prompt-injected shell command away from all of
them.
Inside the sandbox:
* The root filesystem is read-only. The user's workspace is the only writable
path, bound at `/workspace`.
* `/app` and `/tmp` are ephemeral tmpfs, which masks the provider dotenv file
baked into the image.
* `.db/` is masked, so the shell cannot read the chat store. The `read` and
`edit` tools reject those paths as well.
* The environment is cleared and repopulated with `PATH`, `HOME`, `TERM` and
`LANG` only.
* A user namespace is unshared, which is what blocks reading another process's
`/proc//environ`, memory or root.
* The cloud metadata address range is blocked by an `LD_PRELOAD` shim that
intercepts `connect()` for `169.254.0.0/16` and IPv6 link-local ranges.
## Verified behavior
These are the checks the project runs against a live sandbox.
| Attempt | Result |
| --------------------------------------------------------- | ---------------------------------------- |
| Read the parent process environment through `/proc` | denied |
| Grep every `/proc/*/environ` for a planted key | no match outside the shell's own process |
| Reach another tenant's files through `/proc//root` | denied |
| List `/workspace` | only the current tenant's directory |
| Read `/app/.env` | empty, masked by tmpfs |
| Resolve or fetch the metadata server with network enabled | connection refused |
| Any network call with `network=False` | fails, no egress |
## Network
```python theme={null}
llm = cycls.LLM().sandbox(network=True) # default
llm = cycls.LLM().sandbox(network=False) # no egress from bash
```
Network is on by default because most agents need `curl`, `pip` and `git`. A
prompt-injected command can then send anything it can read to an arbitrary host.
**Turn it off when the agent does not need it.** With `network=False` the sandbox
gets a fresh network namespace and no egress at all. Web search still works,
because it runs outside the sandbox.
## What the metadata block does not cover
The `LD_PRELOAD` shim stops every program that uses the C library, which covers
what a model writes in practice. It does not stop:
* statically linked binaries, since they never load the shim
* direct syscall invocation, for example through `ctypes`
* inline assembly
* a child process launched after `unset LD_PRELOAD`
All four require deliberate intent, not an accidental injection. If your threat
model includes users who read the SDK source and craft bypasses, the
architectural answer is a separate deployment per tenant, where a stolen
credential reaches only that tenant's own data.
## Deployment isolation
Deployments under one account share a trust domain: code in one can reach
another's workspace storage. For hard isolation between production and
experiments, or between clients, deploy from a separate organization. Each
organization is its own tenant with its own boundary.
## Local development caveat
The sandbox binds the host root read-only. In production that is the runtime
container's filesystem, which holds no host secrets. When you run an agent
directly on your machine without Docker, local files such as personal credential
stores become readable from the sandbox.
Use `cycls run`, which wraps the agent in Docker, when exercising an agent that
accepts untrusted input with network-enabled bash. Or keep `network=False`, which
removes the exfiltration path.
## Approvals
Isolation bounds what a command can reach. Approvals decide whether it runs at
all. Every tool call is classified before it runs:
| Risk | Behavior |
| ----------- | ---------------------------------- |
| read | always runs |
| write | follows the composer's Auto switch |
| destructive | asks in both modes |
`rm` and `rmdir` are not destructive by default, because the sandbox moves
deletions to a thirty day trash. Commands with nothing behind them, such as
`shred`, `mkfs`, `find -delete`, `dd` with an `of=`, `drop table`,
`git push --force` and a redirect into a disk device, always ask.
`2>/dev/null` is not one of them.
A bash command counts as a read only when every command in the line reads,
such as `ls` or `grep`, and nothing is redirected into a file. So
`ls; touch notes.txt` is a write.
See [Connectors and MCP](/agents/connectors#approvals) for per-tool policy and
organization level switches.
## Recommended settings for untrusted users
```python theme={null}
llm = (
cycls.LLM()
.model("anthropic/claude-sonnet-4-6")
.allowed_tools(["Bash", "Editor", "WebSearch", "Canvas"])
.sandbox(network=False)
.bash_timeout(120)
)
```
1. Turn off sandbox network access unless the agent needs it.
2. Keep deploy credentials out of the image by splitting `.env` and
`.providers.env`.
3. Put anything that must not be readable by a shell outside the workspace, for
example behind a [connector](/agents/connectors) or a deployed function.
4. Use a separate organization for tenants that must not share a trust domain.
## Next
Replace the default loop and keep the building blocks.
# Streaming components
Source: https://docs.cycls.com/agents/streaming
Yield strings for markdown or dicts for rich components: thinking bubbles, tables, code, callouts, images and client actions.
An agent body yields two kinds of value. Strings render as markdown. Dicts render
as components. Both stream as they are produced, so the interface fills in while
the model is still working.
```python theme={null}
@cycls.agent(web=web, volumes={"/workspace": chats})
async def demo(context):
yield {"type": "status", "status": "Checking the fleet"}
yield "Here is what I found:\n\n"
yield {"type": "table", "headers": ["Server", "Status", "CPU"]}
yield {"type": "table", "row": ["web-1", "Online", "45%"]}
yield {"type": "table", "row": ["web-2", "Offline", "0%"]}
yield {"type": "code", "code": "systemctl restart web-2", "language": "bash"}
yield {"type": "callout", "callout": "web-2 needs attention.", "style": "warning"}
```
## Component reference
| Type | Required keys | Behavior |
| ---------- | ---------------------------------- | ------------------------------------------------------------ |
| `text` | `text` | accumulates into one block. A bare string is the same thing |
| `thinking` | `thinking` | accumulates into a collapsible bubble |
| `code` | `code`, `language` | accumulates into one block |
| `table` | `headers` or `row` | renders row by row |
| `callout` | `callout`, `style` | one card. `style` is `info`, `warning`, `error` or `success` |
| `status` | `status` | a single line that replaces the previous status |
| `image` | `src`, optional `alt`, `caption` | one image |
| `sources` | `sources` | citation chips from `[{title, url, snippet}]` |
| `step` | `step`, optional `tool_name`, `id` | a tool step line |
| `ui` | `action` | a client action, not rendered and not stored |
Raw HTML strings pass through, which is the escape hatch for anything the
components do not cover.
## Thinking
Consecutive `thinking` yields append to the same bubble until a different type
arrives. Provider reasoning deltas map here automatically, so extended thinking
from Anthropic and `delta.reasoning` from OpenAI-compatible endpoints both show
up without any work on your side.
```python theme={null}
yield {"type": "thinking", "thinking": "Checking the invoice totals "}
yield {"type": "thinking", "thinking": "against last month."}
yield "The totals match."
```
## Tables
Send the header row once, then one `row` per record. Rows render as they arrive,
so a slow query shows partial results instead of nothing.
```python theme={null}
yield {"type": "table", "headers": ["Region", "Revenue"]}
for region, revenue in await query():
yield {"type": "table", "row": [region, f"{revenue:,.0f}"]}
```
## Client actions
A `ui` event triggers something in the client. Nothing is rendered in the
conversation and nothing is kept in history.
```python theme={null}
if await over_quota(context.user):
yield {"type": "callout", "callout": "Free tier limit reached.", "style": "warning"}
yield {"type": "ui", "action": "open_plan_modal"}
return
```
| Action | Fields | Effect |
| ----------------- | ----------------------- | ---------------------------------------------------------------------------------- |
| `open_plan_modal` | none | opens pricing. The client picks user or organization plans based on the active org |
| `open_canvas` | `path`, optional `name` | opens a workspace file in the canvas viewer |
| `suggest` | `text` | one follow-up chip above the composer |
| `ask` | `questions` | a question card above the composer, up to three questions |
The `Canvas`, `Suggest` and `Ask` [built-in tools](/agents/tools) fire these for
you. Yield them directly only from a custom loop. One difference worth knowing:
when the `Ask` tool fires, the turn ends, because the next user message carries
the answers.
## Errors
An unhandled exception inside the body becomes a callout with a short reference
id, and the full traceback goes to structured logs.
```
Something went wrong. Reference: abc12345
```
Look it up with the reference:
```bash theme={null}
cycls logs my-agent --query 'jsonPayload.error_id="abc12345"'
```
Errors the loop handles itself, such as a rate limit retry or a tool timeout, are
shown as callouts and are not logged as failures.
## Next
What the body receives on every turn.
# Tools
Source: https://docs.cycls.com/agents/tools
Enable built-in tools by name, or register your own Python handlers. Both stream into the same UI.
Tools come from two places. Built-in tools ship with the SDK and are enabled by
name. Custom tools are JSON schemas you define with async handlers you write.
```python theme={null}
llm = (
cycls.LLM()
.model("anthropic/claude-sonnet-4-6")
.allowed_tools(["Bash", "Editor", "WebSearch", "Canvas"]) # built in
.tools(MY_TOOLS) # your schemas
.on("create_ticket", create_ticket) # your handler
)
```
## Built-in tools
Each name enables one or more tools and brings its own prompt guidance, so
enabling it is the only switch you need.
| Name | What the model gets |
| ----------- | ----------------------------------------------------------------------------------------- |
| `Bash` | `bash`: run shell commands in a sandbox rooted at the workspace |
| `Editor` | `read` and `edit`: view and modify workspace files |
| `WebSearch` | `web_search` and `web_fetch`, or the provider's native search |
| `Browser` | `browser`: drive a real Chrome session, only offered when a browser service is configured |
| `DataBase` | `database`: a per-user key-value store with prefix scans |
| `Canvas` | `canvas`: open a finished file in the side panel viewer |
| `Apps` | `build_app`: bundle a source folder into an installable mini app |
| `Suggest` | `suggest`: one follow-up chip above the composer |
| `Ask` | `ask`: up to three questions on one card, which ends the turn |
Runs inside a `bubblewrap` sandbox with the user's workspace bound at
`/workspace` and a sanitized environment. The working directory is the
workspace, scratch belongs in `.tmp/`, and `/tmp` is per-command and discarded.
```python theme={null}
llm = cycls.LLM().allowed_tools(["Bash"]).bash_timeout(600).sandbox(network=True)
```
Network access is on by default so `curl`, `pip` and `git` work. A prompt
injection can exfiltrate anything the sandbox can read, so turn it off when the
agent does not need it:
```python theme={null}
llm = cycls.LLM().allowed_tools(["Bash"]).sandbox(network=False)
```
`read` renders files, including PDFs page by page and images for vision models.
`edit` creates and modifies files. The model is told to use these rather than
`cat` or `sed`, which keeps writes inside the safety checks and off the output
token budget.
One `read` returns at most 50,000 characters and ends with the line to continue
from, so a large file or attachment arrives in pages instead of filling the
context window.
Paths are workspace relative. Traversal outside the workspace is refused, and so
are the reserved internal directories.
```python theme={null}
llm = cycls.LLM().allowed_tools(["WebSearch"]).web_search("brave")
```
`brave` is the portable pair, `web_search` plus `web_fetch`. It works on every
provider and needs `BRAVE_API_KEY`. Results arrive in the UI as one `sources`
row of citation chips at the end of the answer. `native` uses the provider's
server-side search, which is Anthropic only today.
A real Chrome session driven step by step: `open`, `read`, `click`, `type`,
`press`, `back`, `evaluate`, `screenshot`, `download`. The page persists between
calls in a turn, so logins and multi-step forms work.
The browser itself runs in a shared service, not in your image. Configure it with
environment variables, and the tool is not offered when they are unset:
```bash theme={null}
BROWSER_PROVIDER=cycls
BROWSER_URL=https://cycls-browser.cycls.ai
BROWSER_SECRET=your_shared_secret
```
A per-user JSON key-value store with `get`, `put`, `delete` and `scan`. It is
private to the user: no teammate and no app can read it. Use it for agent memory
across chats, such as preferences and task progress.
Users manage it in the web client under Settings, General, Memory. Each entry in
the active workspace shows as a plain-text note they can edit or delete. Turning
Memory off removes the tool from their chats in every workspace and keeps what
was saved.
Anything a teammate or an app needs to read belongs in a workspace file or under
`apps//data/` instead.
`Canvas` opens one finished deliverable in the side panel. It renders markdown,
HTML, PDF, images, audio, video, code, CSV, spreadsheets and 3D models. Office
formats render read-only through the office service, and anything else falls back
to a download card.
`Apps` lets the agent bundle a source folder into a single self-contained HTML
file installed in the Apps tab. Apps run under a strict CSP with no external
fetch, and reach their own data through a small bridge, so a dashboard stays live
without holding any key.
`Suggest` shows one follow-up chip above the composer, written in the user's
voice. `Ask` puts up to three questions on one card and ends the turn, with the
answers arriving as the next user message. The options are shortcuts, not a gate:
the user can type anything instead.
Users can switch both off in Settings, and `context.disabled_tools` reflects that
for the turn.
A user can switch a tool off in Settings. `LLM.run()` drops those tools for the
turn. A switch can never enable a tool the operator did not allow.
## Custom tools
A tool schema is a plain dict. A handler is an async function. The return value
goes to the UI stream and to the model as the `tool_result`, so one return covers
both destinations.
```python theme={null}
TOOLS = [
{
"name": "create_ticket",
"description": "Open a support ticket for the current user.",
"inputSchema": {
"type": "object",
"properties": {
"title": {"type": "string"},
"priority": {"type": "string", "enum": ["low", "high"]},
},
"required": ["title"],
},
}
]
async def create_ticket(args):
ticket = await support.create(args["title"], args.get("priority", "low"))
return f"Created ticket {ticket.id}"
llm = (
cycls.LLM()
.model("anthropic/claude-sonnet-4-6")
.tools(TOOLS)
.on("create_ticket", create_ticket, label=lambda i: i["title"])
)
```
### Handler signature
A handler takes the tool input, and optionally a second argument with request
context.
```python theme={null}
async def create_ticket(args, ctx):
ws = ctx.workspace # the caller's workspace
user = ctx.user # the authenticated user
return f"Created for {user.id}"
```
### Presentation
`.on()` controls how the call appears in the UI.
| Argument | Effect |
| -------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| `label` | a function from input to a string, rendering the step line like `Bash(command)`. Defaults to the first string value in the input |
| `icon` | an image URL shown where a connector logo goes |
| `details=True` | the step opens into Request and Response panes, and the result goes there instead of into the chat. The model still receives all of it |
```python theme={null}
llm = cycls.LLM().on(
"search_orders",
search_orders,
label=lambda i: f"orders for {i['email']}",
icon="https://example.com/logo.png",
details=True,
)
```
### Returning UI components
A handler can return a component dict instead of text, and it renders in the
conversation.
```python theme={null}
async def render_chart(args):
path = await make_chart(args["series"])
return {"type": "image", "src": path, "caption": "Revenue by month"}
```
## Reserved names
`skill` and `find_tools` are reserved by the loop. `skill` loads a
[skill](/agents/knowledge#skills) on demand, and `find_tools` loads a connector's
tools when the model needs them.
## Next steps
A complete tool with a handler, a label and a UI return.
OAuth grants, API keys, MCP servers and per-tool approvals.
# Get User Subscription
Source: https://docs.cycls.com/api-reference/billing/get-user-subscription
/api-reference/openapi.json get /v1/user/subscription
Fetches the billing subscription details for the authenticated user from Clerk.
# Check Name Availability
Source: https://docs.cycls.com/api-reference/deployments/check-name-availability
/api-reference/openapi.json get /v1/deployment/check-name
Check if a deployment name is available for the authenticated user.
Returns whether the name can be used and a reason if not.
# Delete a Service
Source: https://docs.cycls.com/api-reference/deployments/delete-a-service
/api-reference/openapi.json post /v1/deployment/delete
# Deploy
Source: https://docs.cycls.com/api-reference/deployments/deploy
/api-reference/openapi.json post /v1/deploy
Deploy a service. Returns NDJSON stream with progress updates.
Post the source as `source_archive`, the tar.gz, up to 100 MiB. Ship
large files via volumes (`cycls volume put`), not the source archive.
`timeout` is how long a single request to the deployed service may run
raise it for long compute, up to Cloud Run's 3600s ceiling. `cpu` is raised
automatically if `memory` requires more than you asked for.
`concurrency` is how many requests one instance serves at once. Leave it high
for I/O-bound apps; set it to 1 for compute, so each call gets its own instance.
`max_instances` caps how many instances the service may scale to. 0 (default)
leaves scaling to the platform.
`volumes` maps mount paths to volumes (JSON), e.g. `{"/data": "shared"}` or
`{"/data": {"name": "shared", "read_only": true, "sub_path": "v1"}}`.
Required, `{}` declares no storage; the deploy is a complete declaration.
Unknown volumes are created (announced in the stream).
`schedule` fires the deployed function on a cron: `{"cron": "0 3 * * *",
"timezone": "Asia/Riyadh"}`. Empty or omitted removes any schedule, the
same complete-declaration rule.
Events: `BUILDING` → `DEPLOYING` → `DONE` (or `ERROR`)
# List Service
Source: https://docs.cycls.com/api-reference/deployments/list-service
/api-reference/openapi.json get /v1/deployment/list
Lists all services for the authenticated user or organization.
# Cloud API
Source: https://docs.cycls.com/api-reference/introduction
The REST API behind the CLI: deploy services, manage volumes, read logs and run SQL. Authenticate with an API key or a bearer token.
`https://api.cycls.ai` is the control plane. The [CLI](/ship/cli) is a client
for it, so anything `cycls` can do, your own tooling can do too.
Two different hostnames. `api.cycls.ai` is this platform API, the one the CLI
calls to build, deploy and manage things. Your own deployments are served at
`https://.cycls.ai`, and their endpoints are in the
[Agent HTTP API](/reference/agent-api).
## Authentication
Every endpoint accepts either header.
```bash API key theme={null}
curl https://api.cycls.ai/v1/deployment/list \
-H "X-API-Key: $CYCLS_API_KEY"
```
```bash Bearer token theme={null}
curl https://api.cycls.ai/v1/deployment/list \
-H "Authorization: Bearer $TOKEN"
```
Get an API key from [Cycls Cloud](https://cloud.cycls.com). Requests are scoped
to the account that owns the key, so listings return only your resources.
Every endpoint page has a live playground. Paste your key into the
authorization field, fill the parameters, and send the request against
`https://api.cycls.ai` without leaving the page. Start with
[list deployments](/api-reference/deployments/list-service), which is read only.
## What the API covers
| Area | Endpoints |
| ----------- | ------------------------------------------------------------------------------------- |
| Deployments | check a name, deploy, list, delete |
| Volumes | create, list, delete, list objects, delete an object, signed upload and download URLs |
| Logs | fetch log entries, run SQL over logs and billing |
| Billing | read the account subscription |
## Deploying
`POST /v1/deploy` takes a multipart form with the source archive and streams
NDJSON progress events back.
```bash theme={null}
curl -X POST https://api.cycls.ai/v1/deploy \
-H "X-API-Key: $CYCLS_API_KEY" \
-F "function_name=hello" \
-F "source_archive=@source.tar.gz" \
-F "volumes={\"/workspace\": \"hello-chats\"}"
```
Events arrive in order: `BUILDING`, then `DEPLOYING`, then `DONE` or `ERROR`.
Two fields behave as complete declarations rather than patches:
* `volumes` maps mount paths to volumes. `{}` declares no storage. Unknown
volumes are created and the stream announces it.
* `schedule` sets the cron. Omitting it or sending an empty value removes any
existing schedule.
The archive is capped at 100 MiB. Ship large files through a volume instead.
## Moving volume data
Uploads and downloads do not pass through the API. Request a signed URL, then
transfer directly to storage.
```bash theme={null}
curl -X POST https://api.cycls.ai/v1/volume/upload \
-H "X-API-Key: $CYCLS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"volume": "training-data", "object": "models/model.bin"}'
```
The response carries a URL you `PUT` the bytes to. `cycls volume put` and
`cycls volume get` do exactly this, which is why file size is effectively
unlimited.
## Rate limits and errors
| Status | Meaning |
| ------ | ------------------------------------------------------ |
| `401` | missing or invalid credentials |
| `403` | the key does not own the resource |
| `404` | no such deployment or volume |
| `422` | validation error, with the offending field in the body |
| `503` | a backing service is unavailable, retry |
## Endpoint reference
Every endpoint, with parameters, schemas and a request builder, is in the pages
under **Endpoints** in the sidebar. The specification is generated from the live
service and mirrored at
[api.cycls.ai/openapi.json](https://api.cycls.ai/openapi.json).
# Get Service Logs
Source: https://docs.cycls.com/api-reference/logs/get-service-logs
/api-reference/openapi.json post /v1/deployment/logs
Fetch logs from Cloud Logging for a specific service.
Use `cursor` from response as `since` to poll for new logs.
# SQL over your logs and billing
Source: https://docs.cycls.com/api-reference/logs/sql-over-your-logs-and-billing
/api-reference/openapi.json post /v1/sql
Run SQL against your own logs and billing data. Two views are pre-set in your
default dataset:
- `logs` , Cloud Logging entries for your deployments
- `billing`, Cloud Billing rows for your deployments
Each tenant gets their own BigQuery dataset and service account; cross-tenant
references fail at the IAM layer.
# Create a Volume
Source: https://docs.cycls.com/api-reference/volumes/create-a-volume
/api-reference/openapi.json post /v1/volume/create
Explicitly create a volume (idempotent). Deploys referencing an unknown
volume also create it; content operations never do.
# Delete a Volume
Source: https://docs.cycls.com/api-reference/volumes/delete-a-volume
/api-reference/openapi.json post /v1/volume/delete
Refuses while any deployment mounts it. Bucket deletion is backstopped
by GCS soft delete.
# Delete an Object from a Volume
Source: https://docs.cycls.com/api-reference/volumes/delete-an-object-from-a-volume
/api-reference/openapi.json post /v1/volume/delete-object
# Get a Signed Download URL for a Volume Object
Source: https://docs.cycls.com/api-reference/volumes/get-a-signed-download-url-for-a-volume-object
/api-reference/openapi.json post /v1/volume/download
# Get a Signed Upload URL for a Volume Object
Source: https://docs.cycls.com/api-reference/volumes/get-a-signed-upload-url-for-a-volume-object
/api-reference/openapi.json post /v1/volume/upload
Strict: the volume must already exist, uploads never create one.
# List Objects in a Volume
Source: https://docs.cycls.com/api-reference/volumes/list-objects-in-a-volume
/api-reference/openapi.json get /v1/volume/objects
# List Volumes
Source: https://docs.cycls.com/api-reference/volumes/list-volumes
/api-reference/openapi.json get /v1/volume/list
Volumes with the deployments currently mounting them, read live from
the service templates, the sole store of attachments.
# Apps
Source: https://docs.cycls.com/build/apps
Return a FastAPI application and get a URL, sign-in, and per-user storage. Everything a function can do, an app can do too.
`@cycls.app` is a function that returns an ASGI application. Cycls serves it,
gives it a URL, and wires in identity and storage.
```python notes.py theme={null}
import cycls
@cycls.app(auth=cycls.Clerk(), volumes={"/workspace": cycls.Volume("notes")})
def notes():
from fastapi import FastAPI
app = FastAPI()
@app.get("/me")
async def me(user=notes.auth):
return {"id": user.id, "plan": user.plan}
@app.post("/notes")
async def create(body: dict, ws=notes.workspace):
db = cycls.DB(ws)
await db.put(f"docs/{body['id']}", body)
return body
return app
```
```bash theme={null}
cycls run notes.py # localhost:8080, reload on save
cycls run notes.py --remote # https://dev-notes.cycls.ai, hot swap on save
cycls deploy notes.py # https://notes.cycls.ai
```
## The decorator
```python theme={null}
@cycls.app(name=None, image=None, volumes=None, auth=None, memory="1Gi")
```
| Argument | Meaning |
| --------- | -------------------------------------------------------------------- |
| `name` | deployment name and subdomain, defaults to the function name |
| `image` | [`cycls.Image`](/build/images) for packages and bundled files |
| `volumes` | mount paths to [volumes](/build/volumes) |
| `auth` | `cycls.Clerk()` or `cycls.JWT(...)`, see [Authentication](/web/auth) |
| `memory` | container memory |
The body runs inside the container at startup. Import heavy dependencies there,
not at module level, so your local process stays light.
## Identity
With `auth=` set, two dependencies become available on the decorated object.
```python theme={null}
@app.get("/me")
async def me(user=notes.auth): # authenticated User, or 401
return user
@app.get("/files")
async def files(ws=notes.workspace): # that user's storage scope
return [p.name for p in ws.root.iterdir()]
```
`user` carries `id`, `org_id`, `org_slug`, `org_role`, `org_permissions`, `plan`
and `features`. See [Authentication](/web/auth) for providers and claims.
## Storage
`notes.workspace` resolves a per-user scope on the volume mounted at
`/workspace`. Two things live there:
```python theme={null}
@app.post("/notes")
async def create(body: NoteIn, ws=notes.workspace):
db = cycls.DB(ws)
await db.put(f"docs/{uuid4().hex[:12]}", body.dict())
return {"ok": True}
@app.get("/notes")
async def list_notes(ws=notes.workspace):
return [d async for _, d in cycls.DB(ws).items(prefix="docs/")]
```
Atomic per key, prefix scans, JSON values. Backed by the volume, so it survives
redeploys.
```python theme={null}
@app.get("/report")
async def report(ws=notes.workspace):
path = ws.root / "report.csv"
return path.read_text()
```
`ws.root` is a `Path` scoped to that user. Write files there and they persist
exactly like any other volume content.
Both need a volume at `/workspace`. Without one the error arrives on first use,
not at deploy, so declare it whenever the app touches storage.
## Serving a front end
Bundle the HTML with the image and return it from a route.
```python theme={null}
from pathlib import Path
HTML = str(Path(__file__).parent / "index.html")
@cycls.app(image=cycls.Image().copy(HTML, "index.html"), auth=cycls.Clerk())
def dashboard():
from fastapi import FastAPI
from fastapi.responses import HTMLResponse
app = FastAPI()
@app.get("/")
async def index():
pk = dashboard._auth_provider.resolve(dashboard.prod).get("pk", "")
return HTMLResponse(Path("index.html").read_text().replace("__CLERK_PK__", pk))
return app
```
The publishable key is resolved at request time, so the same file works locally
and in production without a build step.
## Running untrusted commands
`cycls.Sandbox` is a fluent wrapper around `bubblewrap` for executing commands
with a read-only root, a cleared environment and no network unless you ask.
```python theme={null}
sandbox = (
cycls.Sandbox()
.setenv(PATH="/usr/local/bin:/usr/bin:/bin")
.network(False)
.timeout(120)
)
@app.post("/run")
async def run(cmd: str, ws=terminal.workspace):
result = await sandbox.bind(str(ws.root), "/workspace").chdir("/workspace").run(
["bash", "-lc", cmd]
)
return {"output": result.output, "code": result.code}
```
| Method | Effect |
| ---------------------------- | ---------------------------------------------------------- |
| `.bind(src, dst)` | read-write bind mount |
| `.ro_bind(src, dst)` | read-only bind mount |
| `.setenv(**vars)` | set environment variables |
| `.chdir(path)` | working directory |
| `.network(on)` | allow network, off by default |
| `.timeout(seconds)` | kill after this long |
| `await .run(argv, env=None)` | returns `stdout`, `stderr`, `code`, `timed_out`, `.output` |
## Programmatic control
```python theme={null}
notes.local() # Docker with hot reload
notes.local(watch=False) # Docker, no watcher
notes.deploy() # production
```
For `python notes.py` to serve, guard it the usual way:
```python theme={null}
if __name__ == "__main__":
notes.local()
```
## Next
The chat layer on top of apps: a managed model loop, tools and a web UI.
# Cron
Source: https://docs.cycls.com/build/cron
Add one argument to a function and the platform calls it on a schedule. No scheduler to run, nothing to drift.
A deployed function can carry a schedule. The platform calls it, so there is no
worker of yours to keep alive.
```python heartbeat.py theme={null}
import cycls
reports = cycls.Volume("daily-reports")
@cycls.function(
schedule=cycls.Cron("0 3 * * *", timezone="Asia/Riyadh"),
volumes={"/reports": reports},
)
def nightly():
from datetime import datetime, timezone
now = datetime.now(timezone.utc)
with open(f"/reports/{now:%Y-%m-%d}.log", "a") as f:
f.write(f"{now.isoformat()} ran\n")
```
```bash theme={null}
cycls deploy heartbeat.py
# [DEPLOYING] Scheduled: 0 3 * * * (Asia/Riyadh)
# [DONE] Deployment complete!
```
Pair a schedule with a [volume](/build/volumes) so the output has somewhere
durable to land. A scraper feeding a dashboard is the usual shape.
## Writing the schedule
```python theme={null}
cycls.Cron("0 3 * * *") # daily at 03:00 UTC
cycls.Cron("*/15 * * * *") # every fifteen minutes
cycls.Cron("0 6 * * 1", timezone="Asia/Riyadh") # Mondays at 06:00 Riyadh
```
Five-field unix cron with IANA timezone names, UTC by default. Both are validated
at deploy time, so a typo is an error rather than a schedule that never fires.
## Source is the truth
The schedule exists because the line exists. Delete `schedule=` and redeploy and
the schedule is gone, which the deploy output confirms. `cycls rm` removes it with
the deployment. There is no pause button and no separate object to keep in sync.
## What can be scheduled
Bare functions only, the kind that deploy as named endpoints. Two cases fail at
import with the fix in the message:
* an app or agent with `schedule=`, since they serve HTTP. Schedule a function
that calls them instead.
* a function that takes `port`, since the port contract deploys a server and a
schedule cannot fire one.
## Semantics
Failed runs are retried, so write idempotent output. Date-keyed files like the
example above make a double fire a harmless overwrite.
A run slower than its interval does not block the next one. If two runs must
never race, take a lock file on the volume.
A single scheduled run has a thirty minute ceiling. Split longer work into slices,
or have the scheduled function fan out with `.map()`.
`cycls logs ` shows each one, with the same structured logging as any other
call.
## Next
Serve a FastAPI application with auth and per-user storage.
# Functions
Source: https://docs.cycls.com/build/functions
Turn any Python function into a container that runs on your laptop, in the cloud, or on a schedule, without writing a Dockerfile.
`@cycls.function` is the bottom layer. It takes a Python function, packages it
with the dependencies you declare, and runs it wherever you point it.
```python simulate.py theme={null}
import cycls
@cycls.function(image=cycls.Image().pip("numpy"))
def simulate(n=1_000_000):
import numpy as np
pts = np.random.rand(int(n), 2)
return float(4 * ((pts ** 2).sum(axis=1) <= 1).mean())
```
Three verbs decide where it runs:
```python theme={null}
simulate.run(1000) # your Docker
simulate.remote(1000) # Cycls Cloud, the code you are holding
simulate.deploy() # Cycls Cloud, frozen and callable by name
```
## How the code gets there
The function is serialized with cloudpickle, bytecode, closures and captured
variables included, then executed inside a container built from your `Image`.
The same pickle runs locally and in the cloud, so what you tested is what ships.
Two version rules keep that safe, and both are enforced for you:
* The container's Python matches your host's major and minor version.
* The container's cloudpickle matches your host's exact version.
Functions defined in the file you deploy travel by value. Functions imported from
another module travel by reference, so the container needs that module. Bundle it
with `cycls.Image().copy("helpers.py")`, and anything copied lands in `/app`,
which is on `sys.path`.
## Running locally
```python theme={null}
result = simulate.run(1000)
```
The image is built or reused, the function executes, and the return value comes
back. Print statements stream to your terminal while it runs.
To look around inside the exact environment the function sees:
```bash theme={null}
cycls shell simulate.py
# Entering cycls/simulate:730f149a (exit to leave)
root@a1b2c3:/app#
```
## The development loop
```bash theme={null}
cycls run simulate.py # rerun on every save, local Docker
cycls run simulate.py --remote # rerun on every save, in the cloud
cycls run simulate.py --n 1000 # arguments bind to the signature
```
Trailing `--name value` arguments bind to the function's parameters. Annotated
parameters convert through their annotation, so `n: int` gets an int.
Unannotated ones are parsed as Python literals, so `--data "[1,2]"` becomes a
list. Anything else stays a string.
A save during a run queues the next run instead of killing the current one.
For anything with several calls, a fan-out, or a mix of local and remote, mark a
driver:
```python theme={null}
@cycls.local_entrypoint
def main(n: int = 1_000_000):
print(simulate.remote(n))
print(simulate.map([10, 20, 30]))
```
The entrypoint runs on your machine on every save, and the verbs inside it decide
where the work happens. Keep driver calls inside it. A top-level
`simulate.remote(...)` fires on every import, including during `cycls deploy`.
## Running in the cloud with current code
```python theme={null}
simulate.remote(1_000_000) # one call
simulate.map([10**6] * 100) # one call per item, ordered results
```
`remote()` ships the live bytecode to an executor, which is a small service
provisioned once per image and shared by every function using that image. The
first call for a given image takes about ninety seconds while it provisions.
After that, a call costs network plus compute. Edit the function, call again, and
the new code runs. There is no redeploy step.
`map()` fans one call per item across autoscaled instances and returns results in
input order. It raises on the first failure, so return errors as data when you
want per-item tolerance:
```python theme={null}
@cycls.function(image=cycls.Image().pip("httpx", "beautifulsoup4"))
def scrape(url):
try:
...
except Exception as e:
return {"url": url, "error": str(e)}
```
## Deploying
```bash theme={null}
cycls deploy simulate.py
# Deployed: https://simulate.cycls.ai
# Call it: cycls.remote("simulate")(...)
```
Deploy reads the function's shape. A function that takes a `port` parameter is a
server and gets its own URL. A bare function becomes a named endpoint, frozen at
deploy time and callable from any machine that has your API key:
```python theme={null}
import cycls
pi = cycls.remote("simulate")(10_000_000)
results = cycls.remote("simulate").map([10**6] * 100)
```
The difference matters. `f.remote()` runs your current code, which is what you
want while developing. `cycls.remote("name")` calls what was deployed, which is
what a caller without your source needs.
## Serving instead of returning
Any function that binds a port is a server:
```python theme={null}
@cycls.function(image=cycls.Image().pip("fastapi", "uvicorn"))
def api(port):
from fastapi import FastAPI
import uvicorn
app = FastAPI()
@app.get("/")
async def root():
return {"ok": True}
uvicorn.run(app, host="0.0.0.0", port=port)
```
```bash theme={null}
cycls run api.py # http://localhost:8080
cycls deploy api.py # https://api.cycls.ai
```
For a service with auth and per-user storage, use [`@cycls.app`](/build/apps),
which is this pattern with the plumbing already done.
## Sizing and limits
```python theme={null}
@cycls.function(
image=cycls.Image().pip("torch"),
cpu=4,
memory="8Gi",
timeout=1800,
concurrency=1,
)
def train(epochs): ...
```
| Argument | Meaning |
| ---------------- | ------------------------------------------------------------- |
| `cpu` | 1, 2, 4, 6 or 8 |
| `memory` | `"512Mi"` through `"32Gi"` |
| `timeout` | seconds a single request may run, up to 3600 |
| `concurrency` | requests one instance serves at once. Set it to 1 for compute |
| `volumes` | mount paths to [volumes](/build/volumes) |
| `schedule` | a [cron](/build/cron) that fires the deployment |
| `python_version` | pin the container's Python, must match your host |
## Keeping state warm
The process lives across calls on an instance, so expensive setup can be paid
once per instance using a mutable default:
```python theme={null}
@cycls.function(image=cycls.Image().pip("fastembed"))
def embed(texts, _model={}):
if "m" not in _model:
from fastembed import TextEmbedding
_model["m"] = TextEmbedding("BAAI/bge-small-en-v1.5")
return [v.tolist() for v in _model["m"].embed(list(texts))]
```
First call loads the model, every call after is warm. That is a self-hosted
embedding API in ten lines.
## Method reference
| Method | Meaning |
| -------------------------- | --------------------------------------------------- |
| `.run(*args, **kwargs)` | execute in local Docker, `port=` serves |
| `.remote(*args, **kwargs)` | execute current code in the cloud |
| `.map(items, workers=16)` | fan current code across instances, ordered |
| `.watch(*args, **kwargs)` | run with file watching |
| `.build()` | build the image without running |
| `.deploy()` | freeze and publish |
| `cycls.remote(name)` | a callable for a deployed endpoint, `.map` included |
## Next
Declare packages, system libraries and bundled files.
# Images
Source: https://docs.cycls.com/build/images
Declare pip packages, system libraries, bundled files and build commands in Python. Cycls hashes the result into a cached Docker image.
`cycls.Image` is the container declaration. Chain methods to describe what the
container needs, then hand it to any decorator with `image=`.
```python theme={null}
image = (
cycls.Image()
.pip("pandas", "duckdb")
.apt("ffmpeg", "libpq-dev")
.copy("./utils.py")
.copy("./models/", "app/models/")
.run("curl -fsSL https://example.com/install.sh | sh")
)
@cycls.function(image=image)
def process(path):
from utils import clean # bundled by .copy()
...
```
## Methods
| Method | What it does |
| ---------------------- | ---------------------------------------------------------- |
| `.pip(*packages)` | install Python packages from PyPI |
| `.apt(*packages)` | install system packages with apt-get |
| `.copy(src, dst=None)` | bundle local files or directories, `dst` defaults to `src` |
| `.run(command)` | run a shell command during the build |
| `.rebuild()` | skip the Docker cache for this image |
Every call returns a new `Image`, so a base image can be branched without
affecting the original.
```python theme={null}
base = cycls.Image().pip("httpx")
worker = base.pip("pandas") # base is unchanged
```
## Bundling files
Copied paths land in `/app`, which is on `sys.path`, so bundled modules import
normally inside the function body.
```python theme={null}
(
cycls.Image()
.copy("./utils.py") # same path inside the container
.copy("./models/", "app/models/") # relocate
.copy("/home/me/configs/app.json") # absolute source
)
```
**Splitting secrets by audience.** Keep `CYCLS_API_KEY` in `.env` on your machine
and provider keys in `.providers.env`, then rename during the copy so the
container sees only what it needs:
```python theme={null}
image = cycls.Image().copy(".providers.env", ".env")
```
The agent's dotenv loader finds `.env` at the expected name, and your deploy key
never enters the image.
## Caching
Cycls hashes the declaration, including the contents of copied files, into a
deterministic Docker tag. Identical inputs reuse the cached build. Change one
package and only what depends on it rebuilds.
The first build of a new image takes a minute or two. After that it is cached
anywhere that config appears, on your machine and in the cloud.
```python theme={null}
image = cycls.Image().pip("numpy").rebuild() # force a clean build
```
## What is already in the image
You do not need to declare the basics. Apps get FastAPI, hypercorn, PyJWT and
cryptography. Agents add the Anthropic and OpenAI SDKs, the MCP client, PDF
tooling, `ripgrep`, `jq`, `curl` and fonts. Declare what your own code needs on
top of that.
## Public static files
Files served at `/public` belong on [`cycls.Web`](/web/interface), not on the
image:
```python theme={null}
web = cycls.Web().copy_public("./assets/logo.png", "./downloads/")
```
They are then reachable at `https://your-app.cycls.ai/public/logo.png`.
## Next
Persistent storage that outlives any single deployment.
# Volumes
Source: https://docs.cycls.com/build/volumes
Named persistent storage you attach to any deployment at any mount path. Data outlives the deployments that use it.
A volume is named storage. Create it once, mount it wherever you need it, and it
stays alive regardless of what gets deployed or deleted around it.
```python theme={null}
data = cycls.Volume("training-data")
@cycls.function(volumes={"/data": data})
def crunch(day):
import pandas as pd
return pd.read_parquet(f"/data/events-{day}.parquet").sum()
```
Inside the container, `/data` is the volume. Files written there survive calls,
instances and redeploys, and any other deployment mounting `training-data` sees
the same files.
## The mental model
A volume is shared files, not a database. Reads and writes are ordinary
filesystem operations, which has two consequences worth knowing up front:
* Concurrent writes to the same file are last write wins. There is no locking.
Give concurrent writers distinct paths, such as one file per user or per run,
and the problem disappears.
* A file written by one deployment becomes visible to others within seconds,
not instantly.
## Creating and attaching
Referencing a volume creates it. The first deploy that mentions `training-data`
brings it into existence and says so in the deploy output, so a typo shows up as
a new volume rather than a silent empty one. To be explicit first:
```bash theme={null}
cycls volume create training-data
```
`volumes=` maps mount paths to volumes on any decorator:
```python theme={null}
state = cycls.Volume("staging-state")
prod = cycls.Volume("app-data")
@cycls.app(name="staging", volumes={
"/workspace": state,
"/prod": prod.read_only(), # visible, not writable
"/users": prod.sub_path("users/123"), # one subtree only
})
def staging(port): ...
```
| Modifier | Effect |
| ------------------ | ---------------------------- |
| `.read_only()` | mount without write access |
| `.sub_path("a/b")` | mount only that subdirectory |
## The workspace convention
`/workspace` is where apps and agents keep state: chats, per-user files,
credentials and the key-value store. Agents require a volume there and the
decorator raises without one. Apps need one only if they use `workspace` or `DB`.
```python theme={null}
@cycls.agent(volumes={"/workspace": cycls.Volume("my-agent")})
async def my_agent(context): ...
```
Nothing is created implicitly. Storage is exactly what your source declares, and
renaming a deployment changes nothing about its data, because the volume
reference is the identity.
`cycls run` ignores volumes. Locally your code sees the local filesystem, which
keeps the development loop fast and offline.
## Moving data in and out
The CLI talks to storage directly, so transfers do not proxy through the API and
file size is effectively unlimited.
```bash theme={null}
cycls volume put training-data ./model.bin models/model.bin
cycls volume get training-data outputs/result.parquet .
cycls volume ls training-data # contents
cycls volume ls training-data models/ # contents under a prefix
cycls volume rm training-data models/old.bin
cycls volume ls # every volume, with attachments
```
This is how you seed a dataset before anything is deployed, and how you pull
results out without writing an endpoint.
## Sharing across deployments
Attachment is by name, so a family of deployments can work on one dataset.
```python theme={null}
shared = cycls.Volume("pipeline")
@cycls.function(volumes={"/pipe": shared})
def extract(day): ... # writes /pipe/raw/.json
@cycls.function(volumes={"/pipe": shared})
def transform(day): ... # reads raw/, writes clean/
@cycls.app(name="dashboard", volumes={"/pipe": shared.read_only()})
def dashboard(port): ... # serves clean/, cannot corrupt it
```
One volume, three deployments, no copying. Put a [schedule](/build/cron) on the
producer and the pipeline runs itself. For a worked version of this with Parquet
and DuckDB, see [Build a data warehouse](/guides/duckdb-warehouse).
## Lifecycle
* `cycls rm ` detaches volumes and leaves the data alone.
Redeploying the same name reattaches them with files intact.
* `cycls volume delete ` is the only way to delete data. It refuses while
any deployment has the volume attached, and names them in the error.
* Deleted volumes stay recoverable for seven days.
## Structured state on a volume
For per-user JSON rather than files, `cycls.DB` is a small key-value store that
writes to the workspace volume.
```python theme={null}
db = cycls.DB(context.workspace)
await db.put("usage/2026-09", {"count": 12})
entry = await db.get("usage/2026-09", {"count": 0})
async for key, value in db.items(prefix="usage/"):
...
await db.delete("usage/2026-08")
```
| Method | Meaning |
| ---------------------------------- | ----------------------------------------------- |
| `await db.get(key, default=None)` | read one key |
| `await db.put(key, value)` | write one key, atomic per key |
| `await db.delete(key)` | remove a key, trailing slash clears a namespace |
| `db.items(prefix=, glob=, limit=)` | async iterator of key and value |
| `db.keys(prefix=)` | keys only |
| `db.scan(prefix=)` | one round trip listing |
## Next
Fire a deployed function on a schedule.
# Changelog
Source: https://docs.cycls.com/changelog
Releases of the Cycls SDK, newest first. Versions match the published packages on PyPI.
Every version listed here is published on
[PyPI](https://pypi.org/project/cycls/). Upgrade with `pip install -U cycls`.
**Tool settings no longer need the secret key.** A person's built-in tool
settings, set from the card's Always allow, the tool permissions in Settings or
the Memory switch, are stored as plain settings instead of encrypted credentials.
They work on every deployment. Before, a deployment without `CYCLS_SECRET_KEY`
failed every message from anyone who had clicked Always allow. The key is now
needed only for connectors.
**A missing key degrades.** Without `CYCLS_SECRET_KEY`, stored connector grants
read as absent and a warning is logged, the same as after a key rotation. Storing
a grant still fails.
**Stricter bash reads.** A command counts as a read only when every command in
the line reads and nothing is redirected into a file, so `ls; touch notes.txt` is
a write. `find -delete` always asks, because it deletes without going through the
trash.
Built-in tool settings saved before this release are not carried over, so anyone
who chose Always allow is asked again until they choose it once more.
**Compaction in two steps.** Past 70% of the context window, the loop first
replaces old tool results and long tool arguments with a placeholder, which needs
no model call. It summarizes only when that would free less than 25% of the
window, and keeps the most recent 30% verbatim either way. The trigger also leaves
room for `.max_tokens()`. Clearing never touches a loaded skill, and the system
prompt and `AGENT.md` are never cleared or summarized. See
[Long conversations](/agents/models#long-conversations).
**Cheaper summaries.** A summary is requested as the loop's own next turn, with
the same system prompt and tools, so the provider serves the conversation from its
cache. A run that ends past the trigger summarizes before it closes, while the
answer is being read, so the next message does not wait. A chat that resumes after
20 minutes idle is cleared at a lower bar, since the provider's cache has mostly
expired by then.
**Bounded reads.** One `read` returns at most 50,000 characters and says where to
continue. Attachments go through `read`, so a single large file can no longer
overflow the window before the model runs.
**Instructions.** `AGENT.md` opens from an Instructions tile in the dock at the
bottom of the right panel, in the canvas editor, and no longer appears in Files.
An empty file opens with examples to start from.
**Memory in Settings.** Settings, General, Memory lists what the `database` tool
has saved for the person as plain-text notes they can edit, or delete with Undo.
The switch that turns the tool off moved here from Tool permissions. New routes:
`GET /memory`, `PUT /memory/{key}` and `DELETE /memory/{key}`.
**A markdown editor.** Markdown files edit in a rich editor with a toolbar, a `/`
menu, drag handles, checklists and tables. Images and videos added to a document
are saved in a `media/` folder beside it, and the markdown view shows them. Each
block takes its direction from its own text, so Arabic and English mix. Markdown
with math, HTML or footnotes still edits as plain text.
**Also in this release**
* Every compaction writes a `level=compaction` log record with its tier, reason,
size and outcome, and the web client tracks it as `context_compacted`.
* A summary that fails, returns no text or runs past five minutes drops the older
turns, so the next request still fits.
* SGLang's context-full error is recognized, so the loop compacts and replays
instead of showing the raw error.
* `compact()` in the custom loop kit now takes `keep` and `max_tokens`.
`session.compact(provider)` works as before.
* Dark mode styles follow the theme chosen in the app, not the operating system.
Ships the current web client again. The 0.0.2.142 package carried a browser
bundle from before the 0.0.2.141 client changes, so apps could not reach their
data through the bridge and Settings showed Connectors on deployments that declare
none. Upgrade if you deployed 0.0.2.142.
**Fewer approval cards.** A destructive command now has to be an actual
invocation, so `shredder.py` or a grep for the word `truncate` no longer asks.
`dd` asks only when it names an `of=`. `2>/dev/null` no longer counts as
destructive, and a redirect into a disk device now does.
**Connectors.** A user connects an account once and the tools that need it get a
live credential. `cycls.OAuth2` runs authorization code with PKCE, `cycls.Key`
takes a pasted key, and `cycls.Endpoint` accepts a private URL that is itself the
credential. Declare them on `Web().connectors()` for the directory and
`LLM().connectors()` for the loop. Credentials are encrypted at rest, calls are
audited with the grant they acted under, and an organization admin can switch a
connector off for everyone.
**Per-tool approvals.** Every call is classified as read, write or destructive.
Reads always run, writes follow the composer's Auto switch, and destructive calls
ask in both modes. An approval binds to the exact arguments shown on the card, so
a changed call asks again. Each person can set allow, ask or never per tool.
**MCP on every provider.** The harness speaks Streamable HTTP itself, so
`cycls.MCP` servers work on any model rather than Anthropic only. Tools are
discovered once and cached, and a session opens only when a tool is called.
`.server_side()` still hands a server to Anthropic's connector.
**Tools load on demand.** A connector contributes one index line to the system
prompt, and the model calls `find_tools` to pull in the schemas it needs. Loaded
tools stay for the rest of the conversation.
**Background runs.** A run now records its own state, survives a dropped
connection, and can be stopped from another container. The client watches a run
it started, reconnects to it, and shows a background indicator. `Web().on_run()`
fires once when a run reaches any terminal status.
**Durable turns.** The user's message is written to disk before the model is
called, turn writes refuse to overwrite, and a reader normalizes in memory while
only the writer repairs on disk.
**Browser automation.** Adding `"Browser"` to `allowed_tools` gives the model a
real Chrome session: open, read, click, type, press, back, evaluate, screenshot
and download, with page state persisting across calls in a turn. Chrome runs in a
shared service configured by `BROWSER_URL` and `BROWSER_SECRET`, not in the agent
image. Includes per-site proxy routing and a stealth engine.
**Office files on the canvas.** Spreadsheets render as an interactive grid and
`.docx` as a formatted document, both in the browser. Presentations render as a
slide viewer and other office formats as a read-only PDF through the shared
render service. Any failure falls back to the download card.
**Apps.** The `Apps` tool bundles a source folder into one self-contained HTML
file installed in the Apps tab. App data is rows in a dedicated slot with
compare-and-swap writes and a 25 MB cap, and an app can call a connected
connector's REST API with the credential attached server-side.
**Analytics as plugins.** One canonical event pipe in the client fanned out to
`cycls.PostHog` and `cycls.GTM`, each scoped by an optional event allowlist.
`cycls.OneSignal` adds web push.
**Also in this release**
* Large tool results spill to a file to compute over instead of being read back
as text.
* `.tmp/{chat}` is the agent's `TMPDIR`, and `/tmp` fails with an explicit reason.
* Tool handlers can declare a second parameter to receive a `ToolContext` with the
user, workspace and chat.
* Google One Tap on the signed-out page, opt in with `cycls.Clerk(one_tap=True)`.
* Search results collapse into one sources row at the end of an answer.
* A shared chat forks into the forker's active workspace.
* The base image is pinned to bookworm, because the sandbox needs a syscall the
newer runtime lacks.
* **Mini apps end to end.** A `build_app` tool, a sandboxed bridge scoped to
`apps//`, and an Apps launcher.
* **Canvas.** Deliverables open while they are being written, tabs share the
strip and shrink like a browser's, the rail folds to icons, and the composer
keeps working while the agent does.
* **Example gallery.** `Web().examples()` turns real share links into cards on the
empty chat screen, with an optional tutorial video card.
* **Suggest and Ask.** One follow-up chip above the composer, and a question card
with up to three questions that ends the turn.
* **Apple IAP per product.** `products` takes a mapping of product id to plan, and
verification hands back the payload.
* Transient 500s from model servers are retried, since capacity failures surface
that way.
* `web_search` searches before answering, with Brave country and language
passthrough.
* Provider clients are cached on the full configuration, `.headers()` carries
proxy authentication, and unmapped `.thinking()` values print a warning.
* **Workspaces.** `Web().workspaces()` gives every user a personal workspace and
lets organizations share team workspaces with role-based access. Each workspace
is a full context: files, chats, `AGENT.md`, skills and key-value store. The
active one is selected per request with the `X-Workspace` header.
* **Skills.** `LLM().skills("skills")` ships skill folders with the agent, and any
`skills//SKILL.md` in a user's workspace joins the catalog automatically.
Only names and descriptions sit in the system prompt.
* **Unified reasoning.** `.thinking("low" | "medium" | "high" | "adaptive" | None)`
is translated into each vendor's dialect, with `.extra_body()` as the escape
hatch.
* **Faster import.** `import cycls` went from about 1.0s to about 40ms through
lazy imports and private subpackages.
* Agent routes cross the pickle boundary as data rather than router instances.
* Failures that affect a user surface as a callout with a reference id, while
plumbing goes to structured logs.
* The file picker and browser share one cached walk, with matching and ordering
moved server-side.
* **Document canvas.** In-chat canvas with media, PDF and sheet renderers, a
Finder-like workspace view, and safe uploads.
* **Composer mentions and paste.** Reference workspace files with `@`, and paste
images directly.
* **Affiliate tracking.** `Web().affiliate(key)` loads a referral tracker and
reports conversions at checkout.
* The canvas tool opens finished deliverables only, not transient files.
* **Cost tracking.** Per-turn usage, per-chat aggregates, and `cycls cost` with
`--since`, `--month` and `--by user|chat|model`.
* **`cycls sql`.** SQL over the `logs` and `billing` tables, scoped to the
deployments your key owns.
* **Structured logging.** `cycls.log()` emits one record with user and chat
attribution. Errors carry a reference id shown to the user, and every tool
dispatch emits a `tool_call` record.
* **Cheaper prompts.** Three cache breakpoints on the system prompt, the last tool
and the last user turn, which cut cached input cost substantially on Anthropic.
* Providers moved to one file each behind a neutral shape, with per-provider
context window and max output tables.
* Loop events became dict factories, which is why `cycls.to_ui` is now an identity
function kept for compatibility.
* Chat storage moved to a one-folder layout with glob scans.
* **The primitives API.** `cycls.Image`, `cycls.Web` and `cycls.LLM` replaced
keyword arguments on the decorators. Each is an immutable fluent builder.
* **The CLI.** `cycls run`, `deploy`, `ls`, `rm`, `logs`, `init` and `version`.
* **OpenAI adapter.** One Chat Completions path covering every OpenAI-compatible
endpoint, alongside the native Anthropic path.
* **Custom tool handlers.** `LLM().on(name, handler)` registers an async function
whose return value reaches both the UI and the model.
* **HTTP routes on agents.** `@my_agent.server.api_route(...)` adds routes to the
same deployment.
* **`cycls.Sandbox`.** Bubblewrap execution as its own primitive, with secure
defaults.
* **Live shares.** Stateless signed URLs replaced frozen share snapshots.
* PDF page extraction for large files, and cost plus duration in the usage footer.
* **The chat client.** A React interface with streaming, file uploads, a file
manager, session list, rename and delete.
* **Share links.** Conversations render as public pages with generated social
preview images, including Arabic and right-to-left layouts.
* **Arabic.** Full interface translation with a language toggle, plus localized
sign-in.
* **Voice input.** Dictation in the composer, with long-press to record.
* **Plans and organizations.** Plan badges, an organization switcher, a pricing
modal, and `?plan=` to open checkout directly.
* **Branding from a CMS.** Logo, name and description pulled at startup.
* LaTeX rendering, drag and drop uploads, auto-retry on connection errors, and an
explore dropdown for related agents.
* **The native agent harness.** A direct Anthropic loop with history,
prompt caching and compaction, replacing the subprocess approach.
* **Sandboxed bash.** Bubblewrap isolation with environment stripping, and editor
paths restricted to the workspace.
* **Sessions and files API.** List, load, rename and delete chats, plus full file
endpoints.
* **Attachments.** Upload limits, media sniffing, and recovery from API errors
inside the loop.
* **Organizations in the JWT.** Organization claims reach `context.user`.
* Per-turn token usage and cost display, and incremental saves during a turn.
* **Streaming UI components.** Yield dicts for thinking bubbles, code blocks,
tables, callouts, status lines and images.
* **The function runtime.** `@cycls.function` with local Docker execution, hot
reload on save, and pre-built base images.
* **Volumes and state.** Persistent key-value and filesystem storage for
deployments.
* **Automatic dotenv loading.** A `.env` bundled with the image is loaded at
import, with no extra dependency.
* **`context.last_message`** and the `Messages` wrapper with `.raw`.
* Moved from Poetry to uv, added the test suite and CI.
* Deployment moved to a two-step upload flow, which lifted the practical limit on
build context size.
* Error propagation from the container reached the interface instead of failing
silently.
* The chat interface was redesigned and gained image attachments.
* Cycls Cloud went live for serverless deployment.
* A single `key` parameter replaced the several per-service keys.
* `copy_public` made it simple to ship static assets.
* Cycls became open source at [github.com/Cycls/cycls](https://github.com/Cycls/cycls).
* Decorator configuration for dependencies and authentication.
* Every deployed agent served a streaming OpenAI-compatible endpoint.
* Automatic web chat interface, with user and context management for multi-turn
agents.
# Core concepts
Source: https://docs.cycls.com/get-started/concepts
The mental model behind Cycls: three layers, four builders, and three verbs.
Four ideas carry most of the SDK. Read them in order and the rest of the
reference makes sense on first pass.
## 1. Three layers
Each layer extends the one below it, so anything a Function can do an App can do,
and anything an App can do an Agent can do.
```mermaid theme={null}
flowchart TD
A["@cycls.agent
chat product, managed LLM loop, web UI"] --> B["@cycls.app
blocking ASGI service, auth, per-user storage"]
B --> C["@cycls.function
containerized Python, volumes, schedules"]
```
| Layer | Input | Output | Use it for |
| ---------------------------- | ------------------ | ---------------------------- | ----------------------------------------------- |
| [Function](/build/functions) | function arguments | return value | batch jobs, APIs, scheduled work, heavy compute |
| [App](/build/apps) | HTTP requests | an ASGI app you return | dashboards, internal tools, custom front ends |
| [Agent](/agents/overview) | `context` | a stream of events you yield | chat products |
## 2. Four builders
Configuration lives in fluent, immutable builders. Every method returns a new
object, so one base config can be branched safely.
```python theme={null}
base = cycls.Image().pip("httpx")
fast = base.pip("orjson") # a new Image
slow = base.apt("ffmpeg") # another new Image, `base` is unchanged
```
`.pip()` `.apt()` `.copy()` `.run()` `.rebuild()`
`.auth()` `.title()` `.brand()` `.analytics()` `.workspaces()`
`.model()` `.system()` `.tools()` `.allowed_tools()` `.price()`
`.read_only()` `.sub_path()`
Decorators accept exactly the builders they need, never loose keyword soup.
```python theme={null}
@cycls.function(image=..., volumes=..., schedule=..., cpu=..., memory=...)
@cycls.app(image=..., volumes=..., auth=...)
@cycls.agent(image=..., web=..., volumes=...)
```
## 3. Three verbs
The same three words work in Python and on the command line.
| Verb | Where the code runs | Which code runs |
| -------- | ------------------- | ------------------------------ |
| `run` | your Docker | the code you are holding |
| `remote` | Cycls Cloud | the code you are holding |
| `deploy` | Cycls Cloud | the code frozen at deploy time |
```python theme={null}
simulate.run(1000) # local Docker
simulate.remote(1000) # cloud, current code
simulate.deploy() # cloud, frozen and named
```
```bash theme={null}
cycls run file.py # local Docker, rerun on save
cycls run file.py --remote # cloud, rerun on save
cycls deploy file.py # freeze and publish
```
`f.remote()` is the development loop. `cycls.remote("name")` calls what was
deployed, from any machine that has your API key. Read
[Local and remote builds](/ship/builds) for what happens in each case.
## 4. Code travels as a pickle
Your function is serialized with cloudpickle, including closures and captured
variables, then executed inside a container built from your `Image`. Two rules
follow, and both are enforced for you:
* The container's Python matches your host's major and minor version.
* The container's cloudpickle matches your host's exact version.
A function defined in the file you deploy travels by value. A function imported
from another module travels by reference, so the container will try to import
that module. If you split code across files, bundle them with
`cycls.Image().copy("helpers.py")` so the import resolves.
## State lives on volumes
Containers are stateless. Anything that must survive a redeploy goes on a
[volume](/build/volumes), which is named storage attached at a mount path.
```python theme={null}
data = cycls.Volume("training-data")
@cycls.function(volumes={"/data": data})
def crunch(day):
import pandas as pd
return pd.read_parquet(f"/data/events-{day}.parquet").sum()
```
Agents keep chats, files and credentials under `/workspace`, so the decorator
requires a volume there:
```python theme={null}
@cycls.agent(volumes={"/workspace": cycls.Volume("my-agent")})
async def my_agent(context):
...
```
Deleting a deployment detaches its volumes and never deletes their data.
## Images are content addressed
Cycls hashes the image declaration, including the contents of copied files, into
a deterministic Docker tag. Identical inputs reuse the cached build everywhere.
Change one package and only that layer rebuilds.
## Next steps
The managed loop, the context object and the event stream.
Run, remote, map, deploy and schedule.
# Introduction
Source: https://docs.cycls.com/get-started/introduction
Cycls is the deep-stack AI SDK for Python. Runtime, interface, intelligence and state are composable primitives in one file, deployed with one command.
Write the agent, the container it runs in, the interface it serves and the
storage it keeps, in one Python file. Then ship it.
```python hello.py theme={null}
import cycls
llm = (
cycls.LLM()
.model("anthropic/claude-sonnet-4-6")
.system("You are a helpful assistant.")
.allowed_tools(["Bash", "Editor", "WebSearch"])
)
@cycls.agent(volumes={"/workspace": cycls.Volume("hello-chats")})
async def hello(context):
async for ev in llm.run(context=context):
yield ev
```
```bash theme={null}
cycls deploy hello.py
# https://hello.cycls.ai
```
That deploy produces a chat interface, sign-in, per-user file storage, chat
history, share links, an OpenAI-compatible API endpoint and a sandboxed tool
loop. There is no Dockerfile, no YAML and no separate infrastructure repository.
## The four layers
| Layer | Primitive | What you declare |
| ------------ | -------------------------------- | ------------------------------------------------------------ |
| Runtime | [`cycls.Image`](/build/images) | pip packages, system packages, bundled files, build commands |
| Interface | [`cycls.Web`](/web/interface) | branding, auth, themes, analytics, workspaces |
| Intelligence | [`cycls.LLM`](/agents/models) | model, system prompt, tools, budgets, reasoning |
| State | [`cycls.Volume`](/build/volumes) | named persistent storage, attached by mount path |
Each primitive is an immutable builder, so every method returns a new object and
one base configuration can be branched without side effects.
## Three decorators
Decorators accept exactly the primitives they need. Each one extends the layer
below it, so anything a function can do, an app can do, and anything an app can
do, an agent can do.
```
@cycls.agent chat product, managed LLM loop, web UI
extends
@cycls.app blocking ASGI service, auth, per-user storage
extends
@cycls.function containerized Python, volumes, schedules
```
Run Python in a container locally, in the cloud, or on a schedule. Freeze it
as a named endpoint anyone with your key can call.
Return a FastAPI application and get a URL, sign-in and per-user storage.
A chat product with a managed model loop, tools, memory, files and a web
interface.
## What an agent includes
Streaming chat in light and dark, English and Arabic, a file canvas, voice
input, share links and generated social previews.
Tool calling, retries, context compaction, cost accounting, and background
runs that survive a dropped connection.
Sandboxed bash, file editing, web search, a real browser, a key-value store,
a canvas, app building, and your own Python handlers.
Per-user files and chats on a volume you own, team workspaces, trash and
restore, and shareable conversations.
Clerk out of the box or any OIDC provider. Organizations, roles, plans and
feature flags arrive as `context.user`.
OAuth and API-key grants a user makes once, with per-tool approvals, an audit
trail, and remote MCP servers on any provider.
## Model support
One builder covers Anthropic natively and every OpenAI-compatible endpoint
through a base URL.
```python theme={null}
cycls.LLM().model("anthropic/claude-sonnet-4-6")
cycls.LLM().model("openai/gpt-5.4")
cycls.LLM().model("groq/llama-3.3-70b").base_url("https://api.groq.com/openai/v1")
cycls.LLM().model("local/qwen3").base_url("http://localhost:8000/v1")
```
Reasoning control, tool calls and streaming are unified across providers. See
[Models](/agents/models).
## Start here
Install, run locally, deploy. About five minutes.
Layers, builders, and the difference between run, remote and deploy.
A guided build with tools, branding and a quota check.
Every command, flag and default.
# Quickstart
Source: https://docs.cycls.com/get-started/quickstart
Install Cycls, run an agent on your machine, then deploy it to a public URL.
**Before you start**
* Python 3.10 or newer
* [Docker](https://www.docker.com/) running, for local runs and builds
* A [Cycls API key](https://cloud.cycls.com) for deploying
* A model key, for example `ANTHROPIC_API_KEY` or `OPENAI_API_KEY`
```bash theme={null}
pip install cycls
```
Check it:
```bash theme={null}
cycls version
```
Cycls reads a `.env` file from the working directory automatically.
```bash .env theme={null}
CYCLS_API_KEY=your_cycls_key
ANTHROPIC_API_KEY=sk-ant-your_key
```
Keep two files. `.env` holds keys that stay on your machine, such as
`CYCLS_API_KEY`. `.providers.env` holds keys the container needs, such as
`ANTHROPIC_API_KEY`. Ship only the second one with
`cycls.Image().copy(".providers.env", ".env")`.
```bash theme={null}
cycls init my_agent
```
This writes `my_agent.py`:
```python my_agent.py theme={null}
import cycls
image = cycls.Image()
chats = cycls.Volume("my-agent-chats")
web = (
cycls.Web()
.auth(cycls.Clerk())
.title("my_agent")
)
llm = (
cycls.LLM()
.model("anthropic/claude-sonnet-4-6")
.system("You are a helpful assistant.")
)
@cycls.agent(image=image, web=web, volumes={"/workspace": chats})
async def my_agent(context):
async for ev in llm.run(context=context):
yield ev
```
Four declarations and one function body. The `image` describes the container,
the `volume` holds chat state, `web` configures the interface, and `llm`
configures the model. The body receives a `context` and yields events.
```bash theme={null}
cycls run my_agent.py
```
Cycls builds the image, starts the container and serves on
`http://localhost:8080`. Every save rebuilds and reloads. The first build takes
a minute or two, later ones are cached.
```bash theme={null}
cycls deploy my_agent.py
# [BUILDING] ...
# [DEPLOYING] ...
# [DONE] https://my-agent.cycls.ai
```
The build happens in the cloud, not on your laptop. The deployment name comes
from the function name, and redeploying the same name updates it in place.
## Give it tools
The managed loop ships with built-in tools. Enabling one is a single call, and
each tool brings its own prompt guidance.
```python theme={null}
llm = (
cycls.LLM()
.model("anthropic/claude-sonnet-4-6")
.system("You are a research assistant. Show your work.")
.allowed_tools(["Bash", "Editor", "WebSearch", "Canvas"])
)
```
Now the agent can search the web, write files into the user's workspace, run
shell commands in a sandbox, and open results on the canvas. See
[Tools](/agents/tools) for the full set.
## Talk to it over HTTP
Every agent serves an OpenAI-compatible endpoint, so existing clients work
without changes.
```bash cURL theme={null}
curl https://my-agent.cycls.ai/chat/completions \
-H "Content-Type: application/json" \
-d '{"messages": [{"role": "user", "content": "Hello"}], "stream": true}'
```
```python OpenAI SDK theme={null}
from openai import OpenAI
client = OpenAI(base_url="https://my-agent.cycls.ai", api_key="unused")
stream = client.chat.completions.create(
model="my-agent",
messages=[{"role": "user", "content": "Hello"}],
stream=True,
)
for chunk in stream:
print(chunk.choices[0].delta.content or "", end="")
```
## Next steps
Run, remote and deploy, and when to use each layer.
The loop, the context object, and what the body yields.
What happens on your machine and what happens in the cloud.
End-to-end examples you can copy.
# Run batch jobs
Source: https://docs.cycls.com/guides/batch-jobs
Fan work across autoscaled instances with map, keep expensive setup warm, and call the result from anywhere with your API key.
This guide is for developers with work that is too slow for one process: scraping
a list, embedding a corpus, converting files, or running a simulation sweep.
**Prerequisites:** `CYCLS_API_KEY`. Docker only if you want to test locally.
## 1. Write the unit of work
Write the function for one item. Parallelism is a call site decision, not a code
change.
```python scrape.py theme={null}
import cycls
image = cycls.Image().pip("httpx", "selectolax")
@cycls.function(image=image, concurrency=1)
def scrape(url: str):
import httpx
from selectolax.parser import HTMLParser
try:
r = httpx.get(url, timeout=30, follow_redirects=True)
tree = HTMLParser(r.text)
title = tree.css_first("title")
return {"url": url, "status": r.status_code,
"title": title.text() if title else None}
except Exception as e:
return {"url": url, "error": str(e)}
```
Returning the error as data matters. `map` raises on the first exception, so a
single bad URL would otherwise abort the batch.
`concurrency=1` gives each call its own instance, which is what you want for
CPU-bound work. Leave it high for I/O-bound work so one instance handles many
calls.
## 2. Test one item locally
```bash theme={null}
cycls run scrape.py --url https://example.com
# {'url': 'https://example.com', 'status': 200, 'title': 'Example Domain'}
```
## 3. Fan out
```python theme={null}
@cycls.local_entrypoint
def main():
urls = [line.strip() for line in open("urls.txt") if line.strip()]
results = scrape.map(urls)
ok = [r for r in results if "error" not in r]
print(f"{len(ok)} of {len(results)} succeeded")
```
```bash theme={null}
cycls run scrape.py
```
`map` runs one call per item across autoscaled instances and returns results in
input order. The entrypoint runs on your machine, so it can read local files and
print progress while the work happens in the cloud.
```python theme={null}
scrape.map(urls, workers=32) # raise the fan-out width
```
## 4. Keep expensive setup warm
The process survives between calls on an instance, so a mutable default holds a
loaded model.
```python embed.py theme={null}
import cycls
@cycls.function(image=cycls.Image().pip("fastembed"), memory="4Gi")
def embed(texts, _model={}):
if "m" not in _model:
from fastembed import TextEmbedding
_model["m"] = TextEmbedding("BAAI/bge-small-en-v1.5")
return [v.tolist() for v in _model["m"].embed(list(texts))]
```
The first call on each instance loads the model. Every later call on that
instance skips the load. Batch the input so each call does real work:
```python theme={null}
@cycls.local_entrypoint
def main():
docs = [line.strip() for line in open("corpus.txt")]
batches = [docs[i:i + 256] for i in range(0, len(docs), 256)]
vectors = [v for batch in embed.map(batches) for v in batch]
print(len(vectors))
```
## 5. Write results where they persist
Return values travel over the wire, so keep them small. Large output belongs on a
[volume](/build/volumes).
```python theme={null}
lake = cycls.Volume("scrapes")
@cycls.function(image=image, volumes={"/out": lake})
def scrape(url: str):
import hashlib, json, pathlib
...
key = hashlib.sha256(url.encode()).hexdigest()[:16]
pathlib.Path(f"/out/pages/{key}.json").write_text(json.dumps(record))
return {"url": url, "key": key}
```
Pull them down later without writing an endpoint:
```bash theme={null}
cycls volume ls scrapes pages/
cycls volume get scrapes pages/6f1a2b3c4d5e6f70.json .
```
## 6. Deploy it as a named endpoint
```bash theme={null}
cycls deploy scrape.py
# Deployed: https://scrape.cycls.ai
# Call it: cycls.remote("scrape")(...)
```
```python theme={null}
import cycls
one = cycls.remote("scrape")("https://example.com")
many = cycls.remote("scrape").map(urls)
```
Any machine with your `CYCLS_API_KEY` can call it, including a laptop with no
source and no Docker. An agent can call it from a
[tool handler](/guides/custom-tool), which keeps heavy dependencies out of the
chat container.
```python theme={null}
import asyncio
read = cycls.remote("scrape")
async def read_pages(args):
return await asyncio.to_thread(read.map, args["urls"])
llm = cycls.LLM().tools(TOOLS).on("read_pages", read_pages)
```
## Sizing
| Workload | Settings |
| ----------------------------- | ------------------------------------------------------------ |
| CPU-bound, one item at a time | `concurrency=1`, `cpu=2` or higher |
| I/O-bound, many small calls | leave `concurrency` high, `cpu=1` |
| Large model in memory | `memory="8Gi"` or more, warm the model in a default argument |
| Long single run | raise `timeout`, up to 3600 seconds |
## Limits
* Payloads should stay well under 30MB per call. Use a volume for anything larger.
* A call times out after one hour. Split longer work or checkpoint it.
* If the executor is replaced mid-fan, the whole fan retries, so side effects
should be idempotent.
* Tracebacks from remote code have correct file names and line numbers but no
source lines, because the container holds bytecode rather than your files.
## Next
Run the same work nightly and leave the output somewhere durable.
# Add a custom tool
Source: https://docs.cycls.com/guides/custom-tool
Define a JSON schema, write an async handler, and register it with .on(). The return value reaches both the user and the model.
This guide is for developers connecting an agent to an API they own. The tool calls your service directly, so the base URL below is a placeholder for yours. You will add
a tool that looks up an order, renders a result in the conversation, and shows
the request and response in an expandable step.
**Prerequisites:** a working agent from [Build your first agent](/guides/first-agent).
## 1. Define the schema
A tool schema is a plain dict. Write the description for the model: say what the
tool does and when to reach for it.
```python theme={null}
TOOLS = [
{
"name": "lookup_order",
"description": (
"Look up one order by its id. Use when the user asks about an order's "
"status, contents or shipping. Returns the order and its line items."
),
"inputSchema": {
"type": "object",
"properties": {
"order_id": {"type": "string", "description": "The order id, for example ORD-10293."},
},
"required": ["order_id"],
},
}
]
```
`inputSchema` and `input_schema` are both accepted.
## 2. Write the handler
A handler is an async function. Its return value is used twice: as the event
streamed to the user, and as the `tool_result` sent back to the model.
```python theme={null}
import httpx
API = "https://api.your-company.com" # your own service, not a Cycls endpoint
async def lookup_order(args):
async with httpx.AsyncClient(timeout=20) as http:
r = await http.get(f"{API}/orders/{args['order_id']}",
headers={"Authorization": f"Bearer {os.environ['ORDERS_KEY']}"})
if r.status_code == 404:
return f"No order named {args['order_id']}."
if r.status_code >= 400:
return f"Error: orders API returned {r.status_code}."
order = r.json()
lines = "\n".join(f"- {i['qty']} x {i['name']}" for i in order["items"])
return f"**{order['id']}** ({order['status']})\n{lines}"
```
Returning a plain string is the simplest case. Returning a component dict renders
that component instead:
```python theme={null}
async def render_chart(args):
path = await build_chart(args["series"])
return {"type": "image", "src": path, "caption": "Revenue by month"}
```
## 3. Register it
```python theme={null}
llm = (
cycls.LLM()
.model("anthropic/claude-sonnet-4-6")
.system("You help customers with their orders.")
.tools(TOOLS)
.on("lookup_order", lookup_order, label=lambda i: i["order_id"])
)
```
| Option | Effect |
| -------------- | -------------------------------------------------------------------------------------------------------- |
| `label` | a function from input to string, shown on the step line. Defaults to the first string value in the input |
| `icon` | an image URL shown beside the step |
| `details=True` | the step expands into Request and Response, and the result goes there instead of into the conversation |
```python theme={null}
.on("lookup_order", lookup_order, label=lambda i: i["order_id"], details=True)
```
Use `details=True` for tools whose raw output is long or uninteresting to read in
the transcript. The model still receives the full result.
## 4. Use the caller's identity
Declare a second parameter and the handler receives request context.
```python theme={null}
async def lookup_order(args, ctx):
user = ctx.user # the authenticated User
ws = ctx.workspace # the caller's workspace
if not user:
return "Sign in to look up orders."
order = await orders.get(args["order_id"], customer=user.id)
return format_order(order)
```
| Attribute | Meaning |
| --------------- | ---------------------------------------- |
| `ctx.user` | the authenticated user, or `None` |
| `ctx.workspace` | the active workspace scope |
| `ctx.chat_id` | the current chat |
| `ctx.approvals` | approvals granted this turn |
| `ctx.auto` | the composer's automatic approval switch |
One-argument handlers keep working unchanged.
## 5. Handle failure explicitly
The loop treats a returned string starting with `Error:` as a failed call, which
is what the step indicator and the `tool_call` log record read.
```python theme={null}
async def lookup_order(args):
try:
...
except httpx.TimeoutException:
return "Error: the orders API timed out. Tell the user to try again."
```
Write the message for the model, since the model is what reads it and decides
what to do next.
## Complete example
```python orders.py theme={null}
import os
import cycls
import httpx
API = "https://api.your-company.com" # your own service, not a Cycls endpoint
TOOLS = [
{
"name": "lookup_order",
"description": (
"Look up one order by its id. Use when the user asks about an order's "
"status, contents or shipping."
),
"inputSchema": {
"type": "object",
"properties": {"order_id": {"type": "string"}},
"required": ["order_id"],
},
}
]
async def lookup_order(args, ctx):
if not ctx.user:
return "Sign in to look up orders."
try:
async with httpx.AsyncClient(timeout=20) as http:
r = await http.get(
f"{API}/orders/{args['order_id']}",
headers={"Authorization": f"Bearer {os.environ['ORDERS_KEY']}"},
params={"customer": ctx.user.id},
)
except httpx.TimeoutException:
return "Error: the orders API timed out."
if r.status_code == 404:
return f"No order named {args['order_id']}."
if r.status_code >= 400:
return f"Error: orders API returned {r.status_code}."
order = r.json()
lines = "\n".join(f"- {i['qty']} x {i['name']}" for i in order["items"])
return f"**{order['id']}** ({order['status']})\n{lines}"
llm = (
cycls.LLM()
.model("anthropic/claude-sonnet-4-6")
.system("You help customers with their orders. Ask for an order id when you need one.")
.tools(TOOLS)
.on("lookup_order", lookup_order, label=lambda i: i["order_id"])
)
@cycls.agent(
image=cycls.Image().pip("httpx").copy(".providers.env", ".env"),
web=cycls.Web().auth(cycls.Clerk()).title("Order support"),
volumes={"/workspace": cycls.Volume("orders-chats")},
)
async def orders(context):
async for ev in llm.run(context=context):
yield ev
```
## When to use a connector instead
Write a custom tool when your code holds the credential, such as a service key
that belongs to the deployment. Use a [connector](/agents/connectors) when the
credential belongs to the end user, such as their own Notion or GitHub account.
Connectors handle the grant, refresh, per-tool approvals and the audit trail.
## Next
A FastAPI service with sign-in and per-user storage.
# Build a data warehouse on a volume
Source: https://docs.cycls.com/guides/duckdb-warehouse
Store Parquet on a Cycls volume, query it with DuckDB from a function, schedule the ingest, and let an agent ask questions in SQL.
This guide is for developers who want analytical queries without running a
database server. A [volume](/build/volumes) holds Parquet files, DuckDB reads
them inside a function, and an agent calls that function as a tool.
**Prerequisites:** `CYCLS_API_KEY`, and Docker if you want to run locally first.
## Architecture
```mermaid theme={null}
flowchart LR
S[Source API or CSV] --> I["ingest()
scheduled function"]
I -->|writes Parquet| V[("volume: lake
/lake/events/*.parquet")]
V -->|read only mount| Q["query()
function"]
Q --> A[Agent tool]
Q --> D[Dashboard app]
```
One writer, many readers. That split matters, because a volume is shared files
with no locking: concurrent writes to the same file are last write wins.
Do not put a DuckDB database file (`.duckdb`) on a shared volume and write to it
from more than one deployment. DuckDB expects exclusive access to a database
file, and the volume gives no locking. Write immutable Parquet files instead, one
per partition, and let readers open them read-only.
## 1. Create the volume and load a file
```bash theme={null}
cycls volume create lake
cycls volume put lake ./events-2026-09-01.csv raw/2026-09-01.csv
cycls volume ls lake raw/
```
## 2. Write the ingest function
```python warehouse.py theme={null}
import cycls
lake = cycls.Volume("lake")
image = cycls.Image().pip("duckdb")
@cycls.function(image=image, volumes={"/lake": lake}, memory="2Gi")
def ingest(day: str):
"""Convert one day of raw CSV into a Parquet partition."""
import pathlib
import re
import duckdb
if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", day):
raise ValueError(f"day must be YYYY-MM-DD, got {day!r}")
raw = f"/lake/raw/{day}.csv"
if not pathlib.Path(raw).exists():
return {"day": day, "status": "missing"}
pathlib.Path("/lake/events").mkdir(parents=True, exist_ok=True)
out = f"/lake/events/{day}.parquet"
con = duckdb.connect()
con.execute(
f"COPY (SELECT * FROM read_csv_auto('{raw}')) "
f"TO '{out}' (FORMAT PARQUET, COMPRESSION ZSTD)"
)
rows = con.execute("SELECT count(*) FROM read_parquet(?)", [out]).fetchone()[0]
return {"day": day, "rows": rows, "path": out}
```
Run it against one day:
```bash theme={null}
cycls run warehouse.py::ingest --day 2026-09-01
# {'day': '2026-09-01', 'rows': 18422, 'path': '/lake/events/2026-09-01.parquet'}
```
`COPY ... TO` takes a literal path, not a bound parameter, so the date is validated before it reaches the SQL string. Writing one file per day makes a rerun idempotent: the same input overwrites the
same partition, which is what makes at-least-once scheduling safe.
## 3. Backfill with a fan-out
```python theme={null}
@cycls.local_entrypoint
def main():
days = [f"2026-09-{d:02d}" for d in range(1, 31)]
for result in ingest.map(days):
print(result)
```
```bash theme={null}
cycls run warehouse.py
```
`map` runs one call per item across autoscaled instances and returns results in
input order.
## 4. Query it
```python theme={null}
@cycls.function(image=image, volumes={"/lake": lake.read_only()}, memory="4Gi")
def query(sql: str, limit: int = 200):
"""Run read-only SQL over every Parquet partition."""
import duckdb
con = duckdb.connect()
con.execute("CREATE VIEW events AS SELECT * FROM read_parquet('/lake/events/*.parquet')")
con.execute("SET enable_external_access = false")
rel = con.execute(f"SELECT * FROM ({sql}) LIMIT {int(limit)}")
columns = [d[0] for d in rel.description]
return {"columns": columns, "rows": [list(r) for r in rel.fetchall()]}
```
```bash theme={null}
cycls run warehouse.py::query --sql "SELECT country, count(*) c FROM events GROUP BY 1 ORDER BY c DESC"
```
Two safeguards are worth keeping. `lake.read_only()` means this deployment cannot
modify the warehouse even if the SQL tries. `SET enable_external_access = false`
stops DuckDB from reading URLs or local paths outside what is already attached.
`query` runs arbitrary SQL. Only expose it to callers you trust, or to an agent
whose output you are willing to treat as user input. The read-only mount limits
the damage to reads, and the external access setting blocks file and network
reads from inside SQL.
## 5. Schedule the ingest
```python theme={null}
import datetime
@cycls.function(
image=image,
volumes={"/lake": lake},
schedule=cycls.Cron("0 2 * * *", timezone="Asia/Riyadh"),
)
def nightly():
day = (datetime.date.today() - datetime.timedelta(days=1)).isoformat()
return ingest.remote(day)
```
```bash theme={null}
cycls deploy warehouse.py
```
A scheduled function is called with no arguments, so `nightly` computes the day
itself and delegates to `ingest`. A rerun overwrites the same partition, which is
what makes at-least-once delivery harmless here.
## 6. Give an agent access
Deploy `query` as a named endpoint, then call it from a tool handler.
```python analyst.py theme={null}
import asyncio
import cycls
TOOLS = [
{
"name": "warehouse_sql",
"description": (
"Run read-only SQL over the events warehouse. "
"One table: events(day DATE, country TEXT, product TEXT, amount DOUBLE)."
),
"inputSchema": {
"type": "object",
"properties": {"sql": {"type": "string"}},
"required": ["sql"],
},
}
]
run_sql = cycls.remote("query")
async def warehouse_sql(args):
result = await asyncio.to_thread(run_sql, args["sql"])
if not result["rows"]:
return "No rows."
header = " | ".join(result["columns"])
body = "\n".join(" | ".join(str(c) for c in row) for row in result["rows"][:50])
return f"{header}\n{body}"
llm = (
cycls.LLM()
.model("anthropic/claude-sonnet-4-6")
.system(
"You answer questions about sales using the warehouse_sql tool. "
"Write one query, read the result, then answer in plain language."
)
.tools(TOOLS)
.on("warehouse_sql", warehouse_sql, label=lambda i: i["sql"][:60])
)
@cycls.agent(
image=cycls.Image().copy(".providers.env", ".env"),
web=cycls.Web().auth(cycls.Clerk()).title("Analyst"),
volumes={"/workspace": cycls.Volume("analyst-chats")},
)
async def analyst(context):
async for ev in llm.run(context=context):
yield ev
```
```bash theme={null}
cycls deploy analyst.py
```
The agent image contains no data and no DuckDB. It calls the deployed `query`
endpoint, which keeps the chat container small and the warehouse in one place.
## 7. Serve a dashboard from the same data
```python dashboard.py theme={null}
import cycls
lake = cycls.Volume("lake")
@cycls.app(image=cycls.Image().pip("duckdb"), volumes={"/lake": lake.read_only()})
def dashboard():
from fastapi import FastAPI
import duckdb
app = FastAPI()
con = duckdb.connect()
con.execute("CREATE VIEW events AS SELECT * FROM read_parquet('/lake/events/*.parquet')")
@app.get("/revenue")
async def revenue():
rows = con.execute(
"SELECT day, sum(amount) AS total FROM events GROUP BY 1 ORDER BY 1"
).fetchall()
return [{"day": str(d), "total": t} for d, t in rows]
return app
```
Three deployments now share one volume: `ingest` writes, `query` and `dashboard`
read. Deleting any of them leaves the data untouched.
## Operational notes
| Concern | What to do |
| -------------- | ------------------------------------------------------------------------------------------------------------------- |
| Partition size | aim for 100MB to 1GB per Parquet file. Smaller files make the glob slow |
| Schema changes | write a new column into new partitions, and use `union_by_name=true` in `read_parquet` when reading across a change |
| Visibility | a file written by one deployment appears to others within seconds, not instantly |
| Memory | DuckDB spills to disk, so set `memory` on the query function to match your largest scan |
| Cost | idle deployments scale to zero. Volume storage is billed on what is stored |
| Backfills | `map()` over a date list, and keep partitions one file per day so reruns are safe |
## Next
Turn a nightly query into a file users can open.
# Serve a FastAPI app
Source: https://docs.cycls.com/guides/fastapi-app
Deploy an ASGI service with sign-in, per-user storage and a bundled front end, using @cycls.app.
This guide is for developers who want a web service rather than a chat product.
You will build a notes API with sign-in, per-user storage and a single-page front
end, then deploy it.
**Prerequisites:** `CYCLS_API_KEY`, and Docker if you want to run locally.
## 1. The smallest app
```python notes.py theme={null}
import cycls
@cycls.app()
def notes():
from fastapi import FastAPI
app = FastAPI()
@app.get("/")
async def root():
return {"ok": True}
return app
```
```bash theme={null}
cycls run notes.py # http://localhost:8080
cycls run notes.py --remote # https://dev-notes.cycls.ai
cycls deploy notes.py # https://notes.cycls.ai
```
The decorated function returns an ASGI application. It runs inside the container
at startup, so import heavy dependencies in the body rather than at module level.
## 2. Add sign-in
```python theme={null}
@cycls.app(auth=cycls.Clerk())
def notes():
from fastapi import FastAPI
app = FastAPI()
@app.get("/me")
async def me(user=notes.auth):
return {"id": user.id, "org": user.org_id, "plan": user.plan}
return app
```
`notes.auth` is a FastAPI dependency. A request without a valid token gets 401.
Any OIDC provider works with `cycls.JWT(jwks_url=...)`. See
[Authentication](/web/auth).
## 3. Add per-user storage
Storage needs a volume at `/workspace`.
```python theme={null}
notes_data = cycls.Volume("notes-data")
@cycls.app(auth=cycls.Clerk(), volumes={"/workspace": notes_data})
def notes():
from fastapi import FastAPI
from pydantic import BaseModel
from uuid import uuid4
app = FastAPI()
class NoteIn(BaseModel):
title: str = ""
body: str
@app.post("/notes")
async def create(note: NoteIn, ws=notes.workspace):
record = {"id": uuid4().hex[:12], **note.model_dump()}
await cycls.DB(ws).put(f"docs/{record['id']}", record)
return record
@app.get("/notes")
async def list_notes(ws=notes.workspace):
return [d async for _, d in cycls.DB(ws).items(prefix="docs/")]
@app.delete("/notes/{note_id}")
async def delete(note_id: str, ws=notes.workspace):
await cycls.DB(ws).delete(f"docs/{note_id}")
return {"ok": True}
return app
```
`notes.workspace` resolves the authenticated user's own scope, so no route needs to
filter by user id. Two users calling `GET /notes` see different data.
## 4. Bundle a front end
Put the HTML beside the Python file and copy it into the image.
```python theme={null}
from pathlib import Path
HTML = str(Path(__file__).parent / "index.html")
@cycls.app(
image=cycls.Image().copy(HTML, "index.html"),
auth=cycls.Clerk(),
volumes={"/workspace": notes_data},
)
def notes():
from fastapi import FastAPI
from fastapi.responses import HTMLResponse
app = FastAPI()
@app.get("/")
async def index():
pk = notes._auth_provider.resolve(notes.prod).get("pk", "")
return HTMLResponse(Path("index.html").read_text().replace("__CLERK_PK__", pk))
return app
```
The publishable key is resolved at request time, so the same HTML works locally
and in production without a build step. The front end sends the token it gets
from Clerk as `Authorization: Bearer `.
## 5. Search with an inverted index
`cycls.DB` has no query language, so build the index you need. Prefix scans are
the primitive.
```
docs/{id} -> the note
idx/{term}/{id} -> 1
```
```python theme={null}
import re
TOKEN = re.compile(r"[a-z0-9]+")
def tokenize(text):
return set(TOKEN.findall(text.lower()))
@app.get("/search")
async def search(q: str, ws=notes.workspace):
db = cycls.DB(ws)
ids = None
for term in tokenize(q):
prefix = f"idx/{term}/"
hits = {k.removeprefix(prefix) async for k, _ in db.items(prefix=prefix)}
ids = hits if ids is None else ids & hits
if not ids:
return []
return [await db.get(f"docs/{i}") for i in ids or []]
```
Write the document and its index entries together:
```python theme={null}
import asyncio
@app.post("/notes")
async def create(note: NoteIn, ws=notes.workspace):
db = cycls.DB(ws)
record = {"id": uuid4().hex[:12], **note.model_dump()}
terms = tokenize(f"{record['title']} {record['body']}")
await asyncio.gather(
db.put(f"docs/{record['id']}", record),
*[db.put(f"idx/{t}/{record['id']}", 1) for t in terms],
)
return record
```
These writes are not transactional. If the process dies between the document
write and an index write, the index is incomplete. For a small app, rebuild the
index on demand. For a larger one, write the document first and treat the index
as derived state you can regenerate.
## 6. Run untrusted commands
`cycls.Sandbox` wraps `bubblewrap` with a read-only root, a cleared environment,
and no network unless you enable it.
```python theme={null}
sandbox = cycls.Sandbox().setenv(PATH="/usr/local/bin:/usr/bin:/bin").network(False).timeout(60)
@app.post("/run")
async def run(cmd: str, ws=notes.workspace):
result = await sandbox.bind(str(ws.root), "/workspace").chdir("/workspace").run(["bash", "-lc", cmd])
return {"output": result.output, "code": result.code, "timed_out": result.timed_out}
```
Only the caller's own workspace is bound, so one user's command cannot see
another user's files.
## 7. Deploy
```bash theme={null}
cycls deploy notes.py
# https://notes.cycls.ai
```
```bash theme={null}
cycls logs notes -f
cycls volume ls notes-data
```
## App or agent
| Need | Use |
| ------------------------------------------------- | ---------------------------------------------- |
| A REST API or dashboard you design | `@cycls.app` |
| A chat product with a model loop, tools and files | `@cycls.agent` |
| Both | `@cycls.agent` plus custom routes on `.server` |
An agent is an app, so you can add routes to an agent with
`@my_agent.server.api_route(...)` instead of deploying two services.
## Next
Fan work across instances with map.
# Build your first agent
Source: https://docs.cycls.com/guides/first-agent
A guided build from an empty directory to a deployed agent with tools, a system prompt, branding and a quota check.
This tutorial is for developers new to Cycls. You will build a research agent
that searches the web, writes files into the user's workspace, and shows the
result on the canvas. It takes about fifteen minutes.
**Prerequisites**
* Python 3.10 or newer
* Docker running, for the local step
* `CYCLS_API_KEY` from [Cycls Cloud](https://cloud.cycls.com)
* `ANTHROPIC_API_KEY`
```bash theme={null}
mkdir atlas && cd atlas
pip install cycls
```
Create two env files. The first stays on your machine, the second ships inside
the container.
```bash .env theme={null}
CYCLS_API_KEY=your_cycls_key
```
```bash .providers.env theme={null}
ANTHROPIC_API_KEY=sk-ant-your_key
```
```python atlas.py theme={null}
import cycls
image = cycls.Image().copy(".providers.env", ".env")
chats = cycls.Volume("atlas-chats")
web = (
cycls.Web()
.auth(cycls.Clerk())
.title("Atlas")
)
llm = (
cycls.LLM()
.model("anthropic/claude-sonnet-4-6")
.system("You are a research assistant. Cite sources and keep answers short.")
)
@cycls.agent(image=image, web=web, volumes={"/workspace": chats})
async def atlas(context):
async for ev in llm.run(context=context):
yield ev
```
Four declarations and a body. `image` describes the container, `chats` is where
conversations and files live, `web` configures the interface and sign-in, and
`llm` configures the model.
```bash theme={null}
cycls run atlas.py
```
The first build takes a minute or two. When it finishes, open
`http://localhost:8080` and ask a question. Saving the file rebuilds and reloads.
Replace the `llm` block:
```python theme={null}
llm = (
cycls.LLM()
.model("anthropic/claude-sonnet-4-6")
.system("You are a research assistant. Cite sources and keep answers short.")
.allowed_tools(["WebSearch", "Bash", "Editor", "Canvas"])
.sandbox(network=False)
)
```
Save, then ask for something that needs work, for example:
> Compare the three largest Saudi banks by total assets and write the result to
> `banks.md`.
The agent searches, writes the file into the workspace, and opens it on the
canvas. Each tool carries its own prompt guidance, so nothing else is needed.
`sandbox(network=False)` removes network access from the bash tool. Web search
still works, because it runs outside the sandbox.
```python theme={null}
web = (
cycls.Web()
.auth(cycls.Clerk())
.title("Atlas")
.brand(name="Atlas", description="Research assistant for market questions")
.suggestions(True)
)
```
The name and description appear on the empty chat screen. Add `logo="./icon.svg"`
once you have an icon.
```python theme={null}
llm = (
cycls.LLM()
.model("anthropic/claude-sonnet-4-6")
.system("You are a research assistant. Cite sources and keep answers short.")
.allowed_tools(["WebSearch", "Bash", "Editor", "Canvas"])
.sandbox(network=False)
.price(input=3, output=15, cache_read=0.30, cache_write=6)
)
```
Prices are USD per million tokens. With them set, every turn logs its cost and
`cycls cost atlas` reports spend.
```python theme={null}
from datetime import datetime, timezone
FREE_MONTHLY_LIMIT = 10
@cycls.agent(image=image, web=web, volumes={"/workspace": chats})
async def atlas(context):
db = cycls.DB(context.workspace)
month = datetime.now(timezone.utc).strftime("%Y-%m")
entry = await db.get(f"usage/{month}", {"count": 0})
if context.prod and context.user.plan == "u:free_user" and entry["count"] >= FREE_MONTHLY_LIMIT:
yield {"type": "callout", "callout": "Free limit reached this month.", "style": "warning"}
yield {"type": "ui", "action": "open_plan_modal"}
return
entry["count"] += 1
await db.put(f"usage/{month}", entry)
async for ev in llm.run(context=context):
yield ev
```
`context.prod` is `False` under `cycls run`, so your local loop is never blocked.
```bash theme={null}
cycls deploy atlas.py
# [DONE] https://atlas.cycls.ai
```
Check it is live, then watch it:
```bash theme={null}
cycls ls
cycls logs atlas -f
cycls cost atlas
```
## The finished file
```python atlas.py theme={null}
from datetime import datetime, timezone
import cycls
image = cycls.Image().copy(".providers.env", ".env")
chats = cycls.Volume("atlas-chats")
web = (
cycls.Web()
.auth(cycls.Clerk())
.title("Atlas")
.brand(name="Atlas", description="Research assistant for market questions")
.suggestions(True)
)
llm = (
cycls.LLM()
.model("anthropic/claude-sonnet-4-6")
.system("You are a research assistant. Cite sources and keep answers short.")
.allowed_tools(["WebSearch", "Bash", "Editor", "Canvas"])
.sandbox(network=False)
.price(input=3, output=15, cache_read=0.30, cache_write=6)
)
FREE_MONTHLY_LIMIT = 10
@cycls.agent(image=image, web=web, volumes={"/workspace": chats})
async def atlas(context):
db = cycls.DB(context.workspace)
month = datetime.now(timezone.utc).strftime("%Y-%m")
entry = await db.get(f"usage/{month}", {"count": 0})
if context.prod and context.user.plan == "u:free_user" and entry["count"] >= FREE_MONTHLY_LIMIT:
yield {"type": "callout", "callout": "Free limit reached this month.", "style": "warning"}
yield {"type": "ui", "action": "open_plan_modal"}
return
entry["count"] += 1
await db.put(f"usage/{month}", entry)
async for ev in llm.run(context=context):
yield ev
```
## Where to go next
Call your own API from the agent.
Parquet on a volume, read with DuckDB.
# Schedule a nightly report
Source: https://docs.cycls.com/guides/scheduled-reports
Run a function on a cron, write the output to a volume, and let an agent hand it to users.
This guide is for developers automating recurring work: a nightly export, a daily
digest, a weekly reconciliation.
**Prerequisites:** `CYCLS_API_KEY`.
## 1. Write the job
A scheduled function is called with no arguments, so it computes its own
parameters.
```python reports.py theme={null}
import datetime
import cycls
reports = cycls.Volume("reports")
image = cycls.Image().pip("duckdb", "openpyxl")
@cycls.function(
image=image,
volumes={"/reports": reports},
schedule=cycls.Cron("0 6 * * *", timezone="Asia/Riyadh"),
memory="2Gi",
timeout=1200,
)
def daily_sales():
import pathlib
import duckdb
day = (datetime.date.today() - datetime.timedelta(days=1)).isoformat()
out = pathlib.Path(f"/reports/sales/{day}.xlsx")
out.parent.mkdir(parents=True, exist_ok=True)
con = duckdb.connect()
con.execute("INSTALL excel; LOAD excel;")
con.execute(
f"""
COPY (
SELECT country, sum(amount) AS revenue, count(*) AS orders
FROM read_parquet('/reports/events/*.parquet')
WHERE day = '{day}'
GROUP BY 1 ORDER BY revenue DESC
) TO '{out}' (FORMAT xlsx, HEADER true)
"""
)
return {"day": day, "path": str(out)}
```
## 2. Test before scheduling
```bash theme={null}
cycls run reports.py::daily_sales
```
The schedule does not fire during `cycls run`. The local run executes the
function once so you can check the output.
## 3. Deploy
```bash theme={null}
cycls deploy reports.py
# [DEPLOYING] Scheduled: 0 6 * * * (Asia/Riyadh)
# [DONE] Deployment complete!
```
The deploy output confirms the schedule. From then on the platform calls the
function, with no worker of yours to keep running.
## 4. Confirm it runs
```bash theme={null}
cycls logs daily-sales -s 24h
cycls volume ls reports sales/
```
Every fire is an ordinary request, so it appears in logs like any other call.
## 5. Change or remove the schedule
Source is the truth. Edit the cron expression and redeploy to change it. Delete
the `schedule=` argument and redeploy to remove it, which the deploy output
confirms with `Schedule removed`. `cycls rm` removes both the deployment and its
schedule.
## 6. Hand the report to users
Point an agent at the same volume and let it read the files.
```python assistant.py theme={null}
import cycls
reports = cycls.Volume("reports")
llm = (
cycls.LLM()
.model("anthropic/claude-sonnet-4-6")
.system(
"Daily sales reports are at /reports/sales/.xlsx. "
"Read the requested day and summarize it. Open the file on the canvas."
)
.allowed_tools(["Bash", "Editor", "Canvas"])
)
@cycls.agent(
image=cycls.Image().copy(".providers.env", ".env"),
web=cycls.Web().auth(cycls.Clerk()).title("Sales assistant"),
volumes={
"/workspace": cycls.Volume("assistant-chats"),
"/reports": reports.read_only(),
},
)
async def assistant(context):
async for ev in llm.run(context=context):
yield ev
```
The read-only mount means the agent can read every report and change none of
them.
## Writing safe scheduled jobs
| Property | What it means for your code |
| --------------------- | ---------------------------------------------------------------------------------------------- |
| At least once | A run can fire twice. Key output by date so a rerun overwrites rather than appends |
| Runs can overlap | A slow run does not block the next. Take a lock file on the volume if two runs must never race |
| Thirty minute ceiling | Split long work, or have the scheduled function call `map()` and return quickly |
| No pause button | Remove `schedule=` and redeploy, which is the only off switch |
A safe job looks like this:
```python theme={null}
out = pathlib.Path(f"/reports/sales/{day}.xlsx") # date-keyed, so reruns overwrite
if out.exists() and not force:
return {"day": day, "status": "already done"}
```
## What cannot be scheduled
* Apps and agents. They serve HTTP. Schedule a function that calls them.
* Functions that take a `port`. The port contract deploys a server, which a
schedule cannot fire.
Both fail at import with the fix in the message.
## Next
Every exported name in one table.
# Open and self-hosted models
Source: https://docs.cycls.com/guides/self-hosted-models
Point an agent at DeepSeek, Kimi, GLM or your own vLLM and SGLang servers, including endpoints behind proxy authentication.
This guide is for developers running open weight models, either on a vendor API
or on their own hardware. Cycls treats both the same way: a `vendor/model`
string, a base URL, and a key.
## Vendor APIs for open weight models
```python theme={null}
import os
import cycls
llm = (
cycls.LLM()
.model("deepseek/deepseek-flash")
.base_url("https://api.deepseek.com/v1")
.api_key(os.environ["DEEPSEEK_API_KEY"])
.system("You are a helpful assistant.")
.thinking("medium")
.context(128_000)
.max_tokens(8_192)
)
```
```python theme={null}
llm = (
cycls.LLM()
.model("moonshotai/kimi-k3")
.base_url("https://api.moonshot.ai/v1")
.api_key(os.environ["MOONSHOT_API_KEY"])
.thinking("high")
.context(1_000_000)
)
```
```python theme={null}
llm = (
cycls.LLM()
.model("zai/glm-5.3")
.base_url("https://open.bigmodel.cn/api/paas/v4")
.api_key(os.environ["ZAI_API_KEY"])
.thinking("medium")
)
```
The vendor prefix picks the reasoning dialect. DeepSeek receives a thinking
toggle plus an effort level, Kimi receives effort tiers, GLM receives a binary
toggle. See [Models](/agents/models#reasoning) for the full mapping.
Model identifiers change between releases. Confirm the current name in the
vendor's documentation before pinning it.
## Your own vLLM or SGLang server
```bash theme={null}
vllm serve moonshotai/Kimi-K3 --port 8000 --served-model-name kimi-k3
```
```python theme={null}
llm = (
cycls.LLM()
.model("local/kimi-k3")
.base_url("http://localhost:8000/v1")
.api_key("unused")
.context(256_000)
.max_tokens(16_384)
)
```
`local` has no reasoning dialect, so `.thinking()` sends nothing and prints one
warning. Pass the parameter your server expects directly:
```python theme={null}
llm = cycls.LLM().model("local/kimi-k3").base_url(...).extra_body({"reasoning_effort": "high"})
```
`http://localhost:8000` resolves inside the container, not on your laptop. When
`cycls run` builds a container, point it at a reachable host such as
`http://host.docker.internal:8000/v1` on macOS, or at the server's network
address.
## Endpoints behind proxy authentication
Some hosts authenticate with headers rather than a bearer token.
```python theme={null}
llm = (
cycls.LLM()
.model("modal/moonshotai/Kimi-K3")
.base_url("https://your-workspace--kimi-k3.modal.direct/v1")
.api_key("unused")
.headers({"Modal-Key": os.environ["MODAL_TOKEN_ID"],
"Modal-Secret": os.environ["MODAL_TOKEN_SECRET"]})
)
```
`.headers()` is sent on every model request. Clients are cached per
configuration, so changing headers creates a new client rather than mutating a
shared one.
## Text-only models
Models without vision reject base64 media and fail the whole request. Turn vision
off and attachments stay in the workspace, with a note naming the file so the
model can open it with a tool.
```python theme={null}
llm = cycls.LLM().model("zai/glm-5.3").base_url(...).vision(False)
```
## Set the context window
Compaction starts at 70% of the window, and the default assumes 1M tokens. Set it
to the real value or long chats will overflow before compaction triggers. See
[Long conversations](/agents/models#long-conversations).
```python theme={null}
llm = cycls.LLM().model("deepseek/deepseek-flash").context(128_000)
```
## Web search on any model
Native server-side search is Anthropic only. The portable pair works everywhere
and needs `BRAVE_API_KEY` in the container.
```python theme={null}
llm = (
cycls.LLM()
.model("moonshotai/kimi-k3")
.base_url("https://api.moonshot.ai/v1")
.allowed_tools(["WebSearch"])
.web_search("brave")
)
```
## MCP on any model
The harness speaks MCP itself, so remote MCP servers work regardless of provider.
`.server_side()` is the exception: it hands the server to Anthropic's connector
and is Anthropic only.
```python theme={null}
llm = cycls.LLM().model("deepseek/deepseek-flash").mcp(
cycls.MCP("https://mcp.example.com/mcp").name("example").token(cycls.env("MCP_TOKEN"))
)
```
## Complete example
```python oss_agent.py theme={null}
import os
import cycls
image = cycls.Image().copy(".providers.env", ".env")
llm = (
cycls.LLM()
.model("deepseek/deepseek-flash")
.base_url("https://api.deepseek.com/v1")
.api_key(os.environ["DEEPSEEK_API_KEY"])
.system("You are a helpful assistant. Be concise.")
.allowed_tools(["Bash", "Editor", "WebSearch", "Canvas"])
.web_search("brave")
.thinking("medium")
.context(128_000)
.max_tokens(8_192)
.price(input=0.27, output=1.10)
)
@cycls.agent(
image=image,
web=cycls.Web().auth(cycls.Clerk()).title("Open agent"),
volumes={"/workspace": cycls.Volume("open-agent")},
)
async def open_agent(context):
async for ev in llm.run(context=context):
yield ev
```
Set prices from the vendor's published rates so `cycls cost` reports real spend.
## Troubleshooting
A reasoning parameter may not exist on that endpoint. Use `.thinking(None)` and
add what the server supports with `.extra_body()`.
Confirm the endpoint implements OpenAI tool calling. Some community servers
accept the field and ignore it. Test with a single simple tool first.
Set `.context()` to the model's real window. The loop compacts based on that
number, and the default is 1M.
Pass `.vision(False)` for text-only models.
## Next
Every exported name in one table.
# llms.txt
Source: https://docs.cycls.com/llms
A machine-readable index of this documentation, for AI tools and agents that retrieve Cycls docs.
[https://docs.cycls.com/llms.txt](https://docs.cycls.com/llms.txt)
# llms-full.txt
Source: https://docs.cycls.com/llms-full
The full text of this documentation in one file, for AI tools and agents that need the complete corpus.
[https://docs.cycls.com/llms-full.txt](https://docs.cycls.com/llms-full.txt)
# Agent HTTP API
Source: https://docs.cycls.com/reference/agent-api
Every endpoint a deployed Cycls agent serves: chat, OpenAI compatibility, chats, files, share links, workspaces, connectors and tool settings.
A deployed agent is an HTTP service at `https://.cycls.ai`. This page lists
its endpoints. For the Cycls Cloud control plane, see the
[API reference tab](/api-reference/introduction).
**Authentication:** routes marked authenticated require
`Authorization: Bearer `, where the token comes from the configured
identity provider. Without [`Web().auth(...)`](/web/auth), the state routers are
not installed and only the public routes exist.
**Workspace selection:** in [multi-workspace mode](/web/workspaces), send
`X-Workspace: ` to act inside a team workspace. No header means the personal
one.
## Chat
| Method | Path | Body | Purpose |
| ------ | ----------------------- | --------------------- | ------------------------------------- |
| `POST` | `/` | `{"messages": [...]}` | the Cycls streaming protocol over SSE |
| `POST` | `/chat` | same | alias of `/` |
| `POST` | `/chat/completions` | OpenAI shape | OpenAI-compatible streaming |
| `POST` | `/chats/{chat_id}/stop` | none | stop a running turn |
| `POST` | `/transcribe` | audio | voice input to text |
```bash theme={null}
curl -N https://my-agent.cycls.ai/ \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{"messages": [{"role": "user", "content": "Hello"}]}'
```
Add `?id=` to continue an existing chat. Without it, a new chat id is
generated.
```python theme={null}
from openai import OpenAI
client = OpenAI(base_url="https://my-agent.cycls.ai", api_key="unused")
stream = client.chat.completions.create(
model="my-agent",
messages=[{"role": "user", "content": "Hello"}],
stream=True,
)
```
The `/chat/completions` endpoint flattens components to text, so existing OpenAI
clients work unchanged. Use `POST /` when you want the component stream.
## Configuration and metadata
| Method | Path | Purpose |
| ------ | ----------------------------- | -------------------------------------------- |
| `GET` | `/config` | title, branding, auth flag, feature switches |
| `GET` | `/og.png` | generated social preview image |
| `GET` | `/favicon.svg` | favicon |
| `GET` | `/robots.txt`, `/sitemap.xml` | crawler files |
| `GET` | `/llms.txt` | a machine-readable summary of the agent |
| `GET` | `/explore` | the agents dropdown list |
| `GET` | `/examples` | the resolved example gallery |
| `GET` | `/public/{path}` | static files from `copy_public` |
## Chats (authenticated)
| Method | Path | Purpose |
| -------- | --------------------------- | ------------------------------------------- |
| `GET` | `/chats` | list chats with titles, timestamps and cost |
| `GET` | `/chats/{id}` | one chat with its messages |
| `PUT` | `/chats/{id}` | create or update |
| `DELETE` | `/chats/{id}` | delete |
| `DELETE` | `/chats/{id}/last-exchange` | remove the last user and assistant turn |
## Files (authenticated)
| Method | Path | Purpose |
| -------- | --------------------- | ------------------------------------------------------------ |
| `GET` | `/files?path=subdir` | list a directory |
| `GET` | `/files/{path}` | download, `?download` sets an attachment header |
| `PUT` | `/files/{path}` | upload, the raw body is the file, multipart is also accepted |
| `POST` | `/files/{path}` | create a directory |
| `POST` | `/files-batch/{path}` | upload several files |
| `PATCH` | `/files/{path}` | rename, body `{"to": "new/path"}` |
| `DELETE` | `/files/{path}` | delete, which moves to trash |
Rendering variants for office files: `?as=slides` returns per-slide images and
`?as=pdf` returns a converted PDF. Both require the office render service to be
configured. See [Files and the canvas](/agents/files).
## Trash (authenticated)
| Method | Path | Purpose |
| -------- | --------------------- | ---------------------- |
| `GET` | `/trash` | list deleted items |
| `POST` | `/trash/{id}/restore` | restore one |
| `DELETE` | `/trash/{id}` | delete one permanently |
| `DELETE` | `/trash` | empty the trash |
## Sharing
| Method | Path | Auth | Purpose |
| -------- | ----------------------------------- | ---- | ----------------------------------- |
| `POST` | `/share` | yes | create a share link for a chat |
| `GET` | `/share` | yes | list share links |
| `DELETE` | `/share/{token}` | yes | revoke |
| `GET` | `/shared/{user}/{token}` | no | the public page |
| `GET` | `/share/{user}/{token}/data` | no | the conversation as JSON |
| `GET` | `/share/{user}/{token}/file/{path}` | no | a file referenced by the share |
| `POST` | `/share/{user}/{token}/fork` | yes | copy it into the caller's workspace |
## Workspaces (authenticated)
Installed only when [`Web().workspaces()`](/web/workspaces) is enabled.
| Method | Path | Purpose |
| -------- | ------------------------------------ | ------------------------------------------------ |
| `GET` | `/workspace` | the active workspace |
| `GET` | `/workspaces` | personal plus teams. `?all=1` for the admin view |
| `POST` | `/workspaces` | create a team workspace |
| `PATCH` | `/workspaces/{id}` | rename or set the icon |
| `DELETE` | `/workspaces/{id}` | delete |
| `GET` | `/workspaces/{id}/members` | list members |
| `PUT` | `/workspaces/{id}/members/{user_id}` | add or change a role |
| `DELETE` | `/workspaces/{id}/members/{user_id}` | remove, or leave |
## Connectors (authenticated)
Installed when [connectors](/agents/connectors) are declared.
| Method | Path | Purpose |
| -------- | --------------------------------- | -------------------------------------------------------- |
| `GET` | `/connectors` | the directory, with connection state |
| `POST` | `/connectors/{name}/authorize` | begin an OAuth grant |
| `GET` | `/connectors/{name}/callback` | OAuth redirect target |
| `PUT` | `/connectors/{name}/key` | store a pasted key or endpoint |
| `DELETE` | `/connectors/{name}` | disconnect |
| `PATCH` | `/connectors/{name}` | switch a connector on or off for this user |
| `GET` | `/connectors/{name}/tools` | tools this connector offers |
| `PUT` | `/connectors/{name}/tools/{tool}` | set allow, ask or never for one tool |
| `GET` | `/connectors/{name}/prompts` | prompts the server publishes |
| `ANY` | `/connectors/{name}/fetch/{path}` | call the connector's REST API with the stored credential |
## Tool settings (authenticated)
| Method | Path | Purpose |
| -------- | --------------- | ---------------------- |
| `GET` | `/tools` | per-user tool switches |
| `PUT` | `/tools/{tool}` | set a switch |
| `DELETE` | `/tools/{tool}` | reset to the default |
## Memory (authenticated)
The person's entries in the `database` tool for the active workspace. Settings
lists them as Memory. Turning Memory off is `PUT /tools/database` with
`{"mode": "never"}`.
| Method | Path | Purpose |
| -------- | --------------- | --------------------------------------------------------- |
| `GET` | `/memory` | list entries as `[{"key": ..., "value": ...}]`, up to 500 |
| `PUT` | `/memory/{key}` | write one entry, body `{"value": ...}` |
| `DELETE` | `/memory/{key}` | delete one entry |
## App data (authenticated)
Used by apps the agent builds. See the `Apps` tool in
[Tools](/agents/tools#built-in-tools).
| Method | Path | Purpose |
| -------- | ------------------------- | -------------- |
| `GET` | `/apps/{slug}/data` | list rows |
| `GET` | `/apps/{slug}/data/{key}` | read one row |
| `PUT` | `/apps/{slug}/data/{key}` | write one row |
| `DELETE` | `/apps/{slug}/data/{key}` | delete one row |
## Your own routes
Add routes with `.server`, which is replayed onto the real router inside the
container:
```python theme={null}
from fastapi import Depends
@my_agent.server.api_route("/webhook", methods=["POST"])
async def webhook(request):
return {"ok": True}
@my_agent.server.api_route("/profile", methods=["GET"])
async def profile(user=Depends(my_agent.auth)):
return {"id": user.id}
```
## Storage layout
Sessions and files live under `/workspace//`, where the scope is the user
or the active workspace. Path traversal is rejected.
## Next
Every variable the SDK and runtime read.
# Environment variables
Source: https://docs.cycls.com/reference/environment
Every variable the Cycls SDK, the agent runtime and its optional services read, with where each one belongs.
Cycls loads a `.env` file from the working directory at import. Variables fall
into three groups by where they belong.
## On your machine
These are for deploying. Keep them out of the container image.
| Variable | Required | Purpose |
| ----------------- | -------------------------- | ----------------------------------------------------------------------------------- |
| `CYCLS_API_KEY` | yes, for any cloud command | authenticates `deploy`, `run --remote`, `ls`, `rm`, `logs`, `cost`, `sql`, `volume` |
| `CYCLS_BASE_URL` | no | overrides the API base, default `https://api.cycls.ai` |
| `DOCKER_BUILDKIT` | no | passed through to Docker for local builds |
```python theme={null}
import cycls
cycls.api_key = "..." # equivalent to CYCLS_API_KEY
cycls.base_url = "..." # equivalent to CYCLS_BASE_URL
```
## Inside the container
These belong in the image, usually through `.copy(".providers.env", ".env")`.
| Variable | Required | Purpose |
| ------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------- |
| `ANTHROPIC_API_KEY` | for `anthropic/*` models | read by the Anthropic SDK |
| `OPENAI_API_KEY` | for OpenAI and any endpoint without `.api_key()` | read by the OpenAI SDK. Also enables voice input |
| `BRAVE_API_KEY` | for portable web search | without it, `WebSearch` falls back to the provider's native search where one exists |
| `CYCLS_SECRET_KEY` | for connectors | encrypts stored credentials. Storing a grant fails without it, and grants already stored read as absent |
Any other key your handlers use, such as a service token for your own API, goes
here too.
Do not put `CYCLS_API_KEY` in the image. It is a deploy credential. Splitting
`.env` for your machine and `.providers.env` for the container keeps it out:
```python theme={null}
image = cycls.Image().copy(".providers.env", ".env")
```
## Optional services
Set these when the matching feature should be available. With any of them unset,
the feature is not offered and nothing else breaks.
| Variable | Feature | Documented in |
| ------------------------------------------------------------------------------------------- | -------------------------------------------------- | ------------------------------------------------------------------ |
| `OFFICE_RENDER_URL` | presentation and PDF rendering | [Files and the canvas](/agents/files#configuring-office-rendering) |
| `OFFICE_RENDER_SECRET` | same | |
| `BROWSER_PROVIDER` | browser automation, `cycls` or `steel` | [Tools](/agents/tools#built-in-tools) |
| `BROWSER_URL` | the browser service base URL | |
| `BROWSER_SECRET` | the browser service secret | |
| `BROWSER_PROXY`, `BROWSER_PROXY_SERVER`, `BROWSER_PROXY_USERNAME`, `BROWSER_PROXY_PASSWORD` | proxy routing for the browser | |
| `BROWSER_HUMANIZE` | human-like input timing | |
| `CYCLS_APP_BUILDER` | name of the app build service, default `app-build` | [Tools](/agents/tools#built-in-tools) |
## Connector relay
Set on deployments that share one registered OAuth redirect across agents.
| Variable | Purpose |
| --------------------- | ---------------------------------------------------------- |
| `CYCLS_RELAY_URL` | where providers send the authorization code |
| `CYCLS_RELAY_SECRET` | signs the state that rides through the provider |
| `CYCLS_RELAY_ORIGINS` | comma-separated agent origins the relay may hand a code to |
## Injected by the platform
Read these, do not set them.
| Variable | Meaning |
| ------------------ | --------------------------------------------------------- |
| `CYCLS_VOLUMES` | JSON map of mount path to backing bucket |
| `CYCLS_WORKSPACE` | the workspace mount path inside the sandbox, `/workspace` |
| `CYCLS_TRASH` | the trash mount path used by the `rm` shim |
| `CYCLS_CRON_TOKEN` | authenticates a scheduled invocation |
| `PORT` | the port the service must bind |
| `K_REVISION` | the running revision, used in run records |
## Checking what a container sees
```bash theme={null}
cycls shell my_agent.py
root@a1b2c3:/app# env | sort
```
## Next
Sizes, timeouts and caps in one table.
# Limits
Source: https://docs.cycls.com/reference/limits
Sizes, timeouts, caps and retention windows across builds, deployments, storage, uploads and the agent loop.
Values that constrain what you can build. Platform limits are enforced by the
service. Defaults are what you get without configuration and can be changed.
## Build and deploy
| Limit | Value | Notes |
| ---------------------------------- | -------------------------------------- | ---------------------------------------------------- |
| Source archive | 100 MiB | ship larger files through a [volume](/build/volumes) |
| Python version | must match your host's major and minor | cloudpickle bytecode does not cross versions |
| cloudpickle version | must match your host exactly | |
| First build of a new image | 1 to 2 minutes | cached after that |
| First executor provision per image | about 90 seconds | one per image configuration |
## Runtime
| Limit | Default | Maximum | Notes |
| --------------------- | --------------- | ------------ | ------------------------------- |
| Request timeout | 1200 seconds | 3600 seconds | set with `timeout=` |
| Memory | `1Gi` | `32Gi` | set with `memory=` |
| CPU | platform choice | 8 | 1, 2, 4, 6 or 8 |
| Concurrency | high | | set to 1 for compute-bound work |
| Cold start after idle | a few seconds | | instances scale to zero |
## Remote calls
| Limit | Value |
| ------------------- | ------------------------------------------ |
| Payload per call | keep well under 30MB |
| Call timeout | one hour |
| `map` fan-out width | 16 workers by default, set with `workers=` |
## Scheduled functions
| Limit | Value |
| ------------------- | ---------------------------------- |
| Single run duration | 30 minutes |
| Delivery | at least once, so runs can repeat |
| Overlap | a slow run does not block the next |
## Storage
| Limit | Value | Notes |
| ------------------------------- | ------------------------- | ---------------------------------------- |
| Volume upload and download size | unlimited through the CLI | `put` and `get` talk to storage directly |
| Volume deletion recovery window | 7 days | |
| Concurrent writes to one file | last write wins | there is no locking |
| Cross-deployment visibility | seconds | not instant |
## Agent loop
| Limit | Default | Notes |
| ------------------------------ | --------------------------------- | ------------------------------------------------------------------------------------------------------ |
| Context window | 1,000,000 tokens | set with `.context()` to match your model |
| Output tokens per request | 8192 | set with `.max_tokens()` |
| Compaction trigger | 70% of the context window | lower when `.max_tokens()` needs the room, see [Long conversations](/agents/models#long-conversations) |
| Kept verbatim after compaction | the most recent 30% of the window | |
| Summary timeout | 300 seconds | older turns are dropped when it fails |
| `read` per call | 50,000 characters | lines cut at 2,000 characters, the model continues with `offset` |
| File size for `read` | 3 MB | larger files are read through bash, larger PDFs by page |
| PDF pages per `read` | 20 | |
| Bash timeout | 600 seconds | set with `.bash_timeout()`, maximum 600000 milliseconds per call |
| Upload per file | 512 MB | set with `.max_upload()` |
| `AGENT.md` | 24 KB | truncated beyond that |
| Skill body | 48 KB | loaded on demand |
| Skill description | 1 KB | always in the system prompt |
| Questions per `ask` card | 3 | |
| Connector API relay response | 5 MB | |
| App write | 25 MB | |
## Retention
| Item | Window |
| ------------------------- | --------------------------------------- |
| Deleted files in trash | 30 days |
| Deleted volumes | 7 days |
| Chats and workspace files | until deleted, they live on your volume |
## Next
Working code for each primitive.
# Python API
Source: https://docs.cycls.com/reference/python
Every name exported by the cycls package, with its purpose and the page that documents it.
Reference for `import cycls`. Names are listed by area.
## Decorators
| Name | Signature | Documented in |
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- |
| `cycls.function` | `(name=None, image=None, *, cpu=None, memory=None, timeout=None, concurrency=None, volumes=None, schedule=None, python_version=None)` | [Functions](/build/functions) |
| `cycls.app` | `(name=None, image=None, *, volumes=None, auth=None, memory="1Gi")` | [Apps](/build/apps) |
| `cycls.agent` | `(name=None, image=None, *, web=None, volumes=None, memory="1Gi")` | [Agents](/agents/overview) |
| `cycls.local_entrypoint` | `(fn)` | [Functions](/build/functions#the-development-loop) |
## Builders
| Name | Purpose | Documented in |
| --------------- | ------------------------------------ | ---------------------------------------------------- |
| `cycls.Image` | container build configuration | [Images](/build/images) |
| `cycls.Volume` | named persistent storage | [Volumes](/build/volumes) |
| `cycls.Cron` | schedule for a deployed function | [Cron](/build/cron) |
| `cycls.Web` | interface, auth, branding, analytics | [Interface](/web/interface) |
| `cycls.LLM` | model, prompt, tools, budgets | [Models](/agents/models) |
| `cycls.MCP` | a remote MCP server | [Connectors and MCP](/agents/connectors#mcp-servers) |
| `cycls.Sandbox` | bubblewrap command execution | [Apps](/build/apps#running-untrusted-commands) |
## Authentication
| Name | Purpose | Documented in |
| ---------------- | ----------------------------------- | -------------------------------------------------------------- |
| `cycls.Clerk` | Clerk provider, hosted defaults | [Authentication](/web/auth#clerk) |
| `cycls.JWT` | any OIDC provider with a JWKS URL | [Authentication](/web/auth#any-oidc-provider) |
| `cycls.GCP` | Firebase and GCP Identity Platform | [Authentication](/web/auth#firebase-and-gcp-identity-platform) |
| `cycls.AppleIAP` | StoreKit 2 entitlement verification | [Plans and quotas](/web/monetization#apple-in-app-purchase) |
| `cycls.User` | the authenticated user model | [Authentication](/web/auth#the-user-object) |
## Connectors
| Name | Purpose | Documented in |
| ---------------- | ------------------------------------------- | ----------------------------------------------------- |
| `cycls.OAuth2` | authorization code grant with PKCE | [Connectors](/agents/connectors#three-kinds-of-grant) |
| `cycls.Key` | a key the user pastes | [Connectors](/agents/connectors#three-kinds-of-grant) |
| `cycls.Endpoint` | a private URL that is itself the credential | [Connectors](/agents/connectors#three-kinds-of-grant) |
| `cycls.env` | a deployment secret resolved at use time | [Connectors](/agents/connectors) |
## Storage
| Name | Purpose | Documented in |
| ----------------- | ----------------------------------------- | ------------------------------------------------------ |
| `cycls.DB` | per-user JSON key-value store on a volume | [Volumes](/build/volumes#structured-state-on-a-volume) |
| `cycls.Workspace` | a resolved storage scope | [Context](/agents/context#the-workspace) |
## Analytics and notifications
| Name | Purpose | Documented in |
| ----------------- | --------------------------- | ---------------------------------------------------------- |
| `cycls.PostHog` | PostHog analytics provider | [Interface](/web/interface#analytics-and-push) |
| `cycls.GTM` | Google Tag Manager provider | [Interface](/web/interface#analytics-and-push) |
| `cycls.OneSignal` | web push provider | [Interface](/web/interface#analytics-and-push) |
| `cycls.log` | one structured log record | [Observability](/ship/observability#emit-your-own-records) |
## Remote execution
| Name | Purpose | Documented in |
| -------------------- | ----------------------------------------------- | --------------------------------------- |
| `cycls.remote(name)` | callable for a deployed endpoint, with `.map()` | [Functions](/build/functions#deploying) |
| `cycls.RemoteError` | raised when a remote call fails | [Functions](/build/functions) |
## Events
| Name | Purpose | Documented in |
| -------------- | ------------------------------------- | -------------------------------------------- |
| `cycls.events` | dict factories for loop events | [Custom loops](/agents/custom-loop#the-kit) |
| `cycls.to_ui` | identity function kept for older code | [Agents](/agents/overview#watching-the-loop) |
## Module configuration
```python theme={null}
import cycls
cycls.api_key = "..." # same as CYCLS_API_KEY
cycls.base_url = "..." # same as CYCLS_BASE_URL
```
## Object methods
### Function, App and Agent
| Method | Available on | Meaning |
| ------------------------------- | ------------ | --------------------------------------------- |
| `.run(*args, **kwargs)` | Function | execute in local Docker |
| `.remote(*args, **kwargs)` | Function | execute current code in the cloud |
| `.map(items, workers=16)` | Function | fan out, ordered results |
| `.build()` | Function | build the image only |
| `.watch(*args, **kwargs)` | Function | run with file watching |
| `.local(port=8080, watch=True)` | App, Agent | serve in Docker |
| `.deploy()` | all | publish |
| `.server` | App, Agent | route registrations replayed in the container |
| `.auth` | App, Agent | FastAPI dependency returning the `User` |
| `.workspace` | App, Agent | FastAPI dependency returning the `Workspace` |
| `.prod` | App, Agent | `True` when deployed |
## Next
Every endpoint a deployed agent serves.
# Contributing
Source: https://docs.cycls.com/resources/contributing
Set up the SDK repository, run the three test tiers, and open a pull request.
The SDK is open source at [github.com/Cycls/cycls](https://github.com/Cycls/cycls)
under the MIT license.
## Set up
```bash theme={null}
git clone https://github.com/Cycls/cycls
cd cycls
uv sync --group test
```
Run the in-repository CLI with `uv run cycls ...` so you exercise your working
tree rather than an installed release.
## Run the tests
Three tiers.
The default. No API calls, no Docker, about two minutes.
```bash theme={null}
uv run pytest tests/
uv run pytest tests/agent/ -v
```
Hits real provider APIs. Needs `ANTHROPIC_API_KEY` and skips silently without
one.
```bash theme={null}
set -a && source .providers.env && set +a
uv run pytest tests/agent/scenarios/test_live.py --live -v
```
```bash theme={null}
cd client
npm test
npm run test:watch
```
Function tests need Docker running.
## Repository layout
```
cycls/
cli.py the CLI
_function/ Function, Image, Volume, Cron, remote
_app/ App, auth, DB, sandbox
_agent/ Agent, harness, tools, web
docs/ reference documents
examples/ runnable examples
tests/ mirrored to source
client/ the chat front end
```
## Open a pull request
1. Fork the repository and create a branch.
2. Make the change, and add tests beside the code they cover.
3. Run `uv run pytest tests/` and the front-end tests when you touch `client/`.
4. Push and open a pull request against `main`.
## What helps most
* Bug reports with a minimal reproduction.
* Examples that show a real pattern end to end.
* Documentation fixes, including anything on this site that is wrong or unclear.
* Provider adapters. Adding one means a single file that conforms to the
`Provider` protocol. See [Custom loops](/agents/custom-loop#the-provider-interface).
# Examples
Source: https://docs.cycls.com/resources/examples
Runnable examples for every primitive: functions, a DuckDB warehouse on a volume, ASGI apps, and agents from minimal to production shaped.
Every example lives in the
[SDK repository](https://github.com/Cycls/cycls/tree/main/examples) and runs with
the CLI.
```bash theme={null}
git clone https://github.com/Cycls/cycls
cd cycls
uv sync --group test
uv run cycls run examples/functions/hello.py --n 8
```
Agent examples read provider keys from `.env` inside the container. Keep
`CYCLS_API_KEY` in `.env` on your machine and `ANTHROPIC_API_KEY` in
`.providers.env`, which the image copies in as `.env`.
## Functions
| Example | Shows |
| ------------------------- | ----------------------------------------------------------- |
| `functions/hello.py` | the smallest function, and argument binding from the CLI |
| `functions/dev_loop.py` | `--remote`, `local_entrypoint`, and `.map()` |
| `functions/fanout.py` | one call per item across instances, errors returned as data |
| `functions/warm_model.py` | an embedding model loaded once per instance |
| `functions/toolchain.py` | apt packages and an arbitrary toolchain in the image |
| `functions/nightly.py` | a scheduled function writing to a volume |
Documented in [Functions](/build/functions) and [Batch jobs](/guides/batch-jobs).
## Warehouse
| Example | Shows |
| ---------------------- | ---------------------------------------------------------- |
| `warehouse/ingest.py` | CSV to partitioned Parquet, a nightly schedule, a backfill |
| `warehouse/query.py` | DuckDB over the partitions on a read-only mount |
| `warehouse/analyst.py` | an agent that calls the deployed query endpoint as a tool |
Documented in [Build a data warehouse](/guides/duckdb-warehouse).
## Apps
| Example | Shows |
| ---------------- | ----------------------------------------------------------- |
| `apps/api.py` | the smallest app, and the `--remote` dev URL |
| `apps/notes/` | an inverted index over `cycls.DB`, with a bundled front end |
| `apps/terminal/` | a per-user sandboxed shell using `cycls.Sandbox` |
Documented in [Apps](/build/apps) and [Serve a FastAPI app](/guides/fastapi-app).
## Agents
| Example | Shows |
| ---------------------- | -------------------------------------------------------------- |
| `agents/minimal.py` | the smallest agent, with built-in tools and cost tracking |
| `agents/tools.py` | custom tool schemas, handlers, labels and a component return |
| `agents/open_model.py` | DeepSeek, Kimi, GLM and your own vLLM or SGLang server |
| `agents/super.py` | production shape: branding, CMS, workspaces, analytics, quotas |
| `agents/catalog.py` | a connector catalog declared once and shared |
Documented in [Agents](/agents/overview) and
[Build your first agent](/guides/first-agent).
## Services
| Example | Shows |
| ------------------ | ------------------------------------------------------------- |
| `browser_service/` | deploying the shared browser service the `Browser` tool calls |
## Related repositories
Source, tests and examples.
Longer end-to-end builds.
# FAQ
Source: https://docs.cycls.com/resources/faq
Answers to common questions about Docker requirements, model support, storage, pricing boundaries and how Cycls compares to other frameworks.
Only for local builds. `cycls run` and `cycls shell` build on your machine and
need the Docker daemon. `cycls run --remote` and `cycls deploy` build in the
cloud, so a laptop without Docker can still develop and ship. See
[Local and remote builds](/ship/builds).
Anthropic through the native API, and any OpenAI-compatible endpoint through a
base URL. That covers OpenAI, Azure, Gemini compatible endpoints, Groq, xAI,
Mistral, DeepSeek, GLM, Qwen, Kimi, OpenRouter, vLLM, SGLang and private
gateways. Reasoning control is translated per vendor. See
[Models](/agents/models).
Yes. A function or app body is ordinary Python, so any library you declare in
`cycls.Image().pip(...)` works. Use `@cycls.function` or `@cycls.app` and call
whatever you like. `cycls.LLM` is the managed loop, not a requirement.
The SDK is open source under MIT at
[github.com/Cycls/cycls](https://github.com/Cycls/cycls). Cycls Cloud is the
managed service that builds, hosts and scales deployments.
On volumes you declare. Chats, files and credentials live under the volume
mounted at `/workspace` for that deployment. Deleting a deployment detaches
volumes and never deletes their data. See [Volumes](/build/volumes).
Nothing. State lives on the volume, and the volume reference is the identity.
Redeploying the same name reattaches the same storage with files intact.
Yes. Attachment is by name, so several deployments can mount the same volume.
Use `.read_only()` for consumers that must not write. See
[Sharing across deployments](/build/volumes#sharing-across-deployments).
It runs in a bubblewrap sandbox with a read-only root, a cleared environment, the
user's workspace as the only writable path, and the cloud metadata range blocked.
Network access is on by default and can be turned off with
`.sandbox(network=False)`. The full threat model, including documented bypasses,
is in [Sandbox and trust model](/agents/sandbox).
Deployments are served at `https://.cycls.ai`. For a custom domain, contact
the team through [cycls.com/contact](https://cycls.com/contact).
Set token prices with `cycls.LLM().price(...)`, then use `cycls cost ` for
totals or `cycls sql` for arbitrary slices. Infrastructure cost is in the
`billing` table. See [Logs, cost and SQL](/ship/observability).
Yes. Agents extend apps, so `@my_agent.server.api_route(...)` adds routes to the
same deployment, and `Depends(my_agent.auth)` protects them with the same
identity the chat uses.
`f.remote()` runs your current code on a cloud executor, which is the development
loop. `cycls.remote("name")` calls what was deployed under that name,
which is what callers without your source use.
## Still stuck
Report a bug or request a feature.
Reach the team.
# Local and remote builds
Source: https://docs.cycls.com/ship/builds
Cycls builds a container three ways: on your Docker, on a shared cloud executor, and on the deploy service. Know which one runs and what it contains.
This page is for developers who want to know where a build happens, what goes
into it, and how long it takes. It applies to functions, apps and agents, since
all three share one build pipeline.
## The three paths
| Command | Where the image is built | Docker needed locally | Which code runs |
| ---------------------------- | ------------------------ | --------------------- | ------------------------- |
| `cycls run file.py` | your machine | yes | the code you are holding |
| `cycls run file.py --remote` | Cycls Cloud | no | the code you are holding |
| `cycls deploy file.py` | Cycls Cloud | no | the code frozen at deploy |
`cycls shell file.py` and `f.build()` also build on your machine.
## Local build
```bash theme={null}
cycls run examples/hello.py
```
Cycls generates a Dockerfile, assembles a context directory, and calls your local
Docker daemon. The container then runs with the port published, and the file
watcher rebuilds on save.
The generated Dockerfile is deterministic:
```docker theme={null}
FROM python:3.12-slim-bookworm
ENV PIP_ROOT_USER_ACTION=ignore PYTHONUNBUFFERED=1
WORKDIR /app
RUN pip install uv
RUN apt-get update && apt-get install -y --no-install-recommends
RUN uv pip install --system --no-cache
RUN
COPY context_files/ /app/
```
The base image is `python:-slim-bookworm`. The Python version is
pinned to your host's major and minor version, because cloudpickle bytecode does
not cross Python versions.
**When to use it:** you have Docker, you want no cloud round trip, and your
dependencies are small enough that a rebuild is quick.
**When not to use it:** the image is large, your machine is slow, or you are on a
laptop without Docker. Use `--remote` instead.
## Remote build for functions
```bash theme={null}
cycls run examples/simulate.py --remote --n 1000
```
The function's bytecode is sent to an executor, which is a small service
provisioned once per image configuration and named `exec-`. Every function
that declares the same image shares it.
```
first call about 90 seconds while the executor is provisioned
later calls network plus compute, roughly 1 second per save
```
Print statements from the remote process stream back to your terminal while it
runs. Edit the function, save, and the new bytecode runs on the same warm
executor with no redeploy.
Executors appear in `cycls ls` as `exec-*`. They scale to zero and cost nothing
while idle. `cycls rm` removes one.
## Remote build for apps and agents
```bash theme={null}
cycls run examples/notes.py --remote
# https://dev-notes.cycls.ai
# │ 200 GET /
```
A dev service named `dev-` is deployed once. Each save hot swaps the
running application inside it, and the request log streams into your terminal.
This gives you a public HTTPS URL during development, which is what OAuth
callbacks and mobile clients need.
## Deploy build
```bash theme={null}
cycls deploy notes.py
# Checking 'notes'...
# Deploying 'notes'...
# [BUILDING] ...
# [DEPLOYING] ...
# [DONE] https://notes.cycls.ai
```
Deploy does three things:
1. Checks that the name is available for your account.
2. Builds a source archive locally: the generated Dockerfile, your copied files
under `context_files/`, `function.pkl` holding the cloudpickled function, and
an entrypoint.
3. Uploads the archive as `tar.gz` to the deploy API and streams build events
back as NDJSON: `BUILDING`, then `DEPLOYING`, then `DONE` or `ERROR`.
The container image is built in the cloud, so deploying does not need Docker
locally.
The source archive is capped at 100 MiB. Ship large files through a
[volume](/build/volumes) with `cycls volume put`, not through `.copy()`.
## Image caching
Cycls hashes the image declaration into the tag. The hash covers:
* the base image and Python version
* pip packages, apt packages and run commands
* the path and content hash of every copied file
Identical inputs reuse the cached image. Changing one package rebuilds only from
that layer onward. Force a clean build with `.rebuild()`:
```python theme={null}
image = cycls.Image().pip("numpy").rebuild()
```
## Verify what was built
```bash theme={null}
cycls shell notes.py
# Entering cycls/notes:730f149a (exit to leave)
root@a1b2c3:/app# uv pip list
root@a1b2c3:/app# ls /app
```
`cycls shell` builds or reuses the same image and drops you into `/app`. Use it
to confirm a package landed or a build command worked before adding more.
## Timing
| Event | Typical duration |
| ----------------------------------------- | -------------------- |
| First local build of a new image | 1 to 2 minutes |
| Cached local rebuild | a few seconds |
| First executor provision per image config | about 90 seconds |
| Warm remote call | network plus compute |
| Cold start of an idle deployment | a few seconds |
## Troubleshooting
Local builds need the daemon. Start Docker Desktop on macOS or Windows, or
`sudo systemctl start docker` on Linux. To avoid Docker entirely, use
`cycls run --remote` and `cycls deploy`.
`.copy()` paths are resolved when the image hash is computed, relative to your
working directory. Check the path, or pass an absolute one.
A module imported from another file of yours travels by reference, not in the
pickle. Bundle it with `cycls.Image().copy("helpers.py")`. Copied files land in
`/app`, which is on `sys.path`.
Builder code is pickled by value. Module-level names referenced inside class
bodies or dataclasses defined in the deployed function can fail to resolve in the
container. Reproduce with `cycls run` before deploying.
Pass another port: `cycls run file.py` serves on 8080 by default, and
`my_app.local(port=3000)` changes it.
## Next
Names, sizing, environment variables and what survives a redeploy.
# CLI reference
Source: https://docs.cycls.com/ship/cli
Every cycls command with its arguments, flags, defaults and output: run, deploy, shell, ls, rm, logs, cost, sql, volume, init, version.
The CLI mirrors the Python API. `run` is local, `--remote` is cloud, `deploy`
freezes.
```bash theme={null}
pip install cycls
cycls version
```
**Authentication:** set `CYCLS_API_KEY` in the environment or a `.env` file, or
assign `cycls.api_key` in Python. `CYCLS_BASE_URL` overrides the default
`https://api.cycls.ai`.
**Target syntax:** every command that takes a file accepts `path.py`, or
`path.py::name` to select one of several decorated objects in that file. Each
command imports the file, so keep it free of top-level side effects such as a
bare `.run()` or `.deploy()` call.
## Command summary
| Command | Purpose |
| -------------------------------------------- | ------------------------------- |
| [`cycls init [name]`](#cycls-init) | scaffold a starter agent file |
| [`cycls run `](#cycls-run) | development loop, rerun on save |
| [`cycls deploy `](#cycls-deploy) | build and publish |
| [`cycls shell `](#cycls-shell) | bash inside the built image |
| [`cycls ls`](#cycls-ls) | list deployments |
| [`cycls rm `](#cycls-rm) | delete a deployment |
| [`cycls logs `](#cycls-logs) | fetch or tail logs |
| [`cycls cost `](#cycls-cost) | aggregate model spend |
| [`cycls sql [query]`](#cycls-sql) | SQL over logs and billing |
| [`cycls volume `](#cycls-volume) | manage persistent storage |
| [`cycls version`](#cycls-version) | print the installed version |
## cycls init
Scaffold a starter agent at `.py`.
```bash theme={null}
cycls init notes
# Created notes.py
#
# Next steps:
# cycls run notes.py # run locally in Docker
# cycls deploy notes.py # deploy to production
```
| Argument | Required | Default |
| -------- | -------- | ---------- |
| `name` | no | `my_agent` |
The command fails if the file already exists.
## cycls run
Watch the file and any copied files, and rerun on every save.
```bash theme={null}
cycls run app.py # local Docker
cycls run app.py --remote # cloud
cycls run simulate.py --n 1000 # bind arguments to the signature
```
| Flag | Meaning |
| ------------------ | ------------------------------------------- |
| `--remote` | run in the cloud instead of local Docker |
| `-- ` | bind to the target's parameter of that name |
Argument binding rules: an annotated parameter converts through its annotation,
so `n: int` receives an int. An unannotated parameter is parsed as a Python
literal, so `--data "[1,2]"` becomes a list. Anything else stays a string.
Behavior by target:
* **Functions** rerun locally in Docker, or on a warm cloud executor with
`--remote`. Remote `print()` output streams back live.
* **Apps and agents** serve on `localhost:8080`, or on `https://dev-.cycls.ai`
with `--remote`, where each save hot swaps the running application.
A save during a run queues the next run rather than killing the current one.
With `@cycls.local_entrypoint`, the entrypoint always runs locally and the verbs
inside it decide where work happens, so `--remote` does not apply and is
rejected.
## cycls deploy
Build and publish to Cycls Cloud.
```bash theme={null}
cycls deploy notes.py
# Checking 'notes'...
# Deploying 'notes'...
# [BUILDING] ...
# [DEPLOYING] ...
# [DONE] https://notes.cycls.ai
```
The deployment name comes from the decorator's `name=` or the function name.
Redeploying a name updates it in place. See [Deploy](/ship/deploy) for sizing,
limits and the isolation boundary.
## cycls shell
Open an interactive bash session inside the target's built image, the same
environment `run` and `deploy` execute in.
```bash theme={null}
cycls shell examples/c.py
# Entering cycls/triangle:730f149a (exit to leave)
root@a1b2c3:/app# gcc --version
```
Builds or reuses the cached image, drops you in `/app`, and cleans up on exit.
## cycls ls
```bash theme={null}
cycls ls
# super-stage https://super-stage.cycls.ai [us-central1] 2026-05-15T18:14
# notes https://notes.cycls.ai [us-central1] 2026-05-10T09:00
```
Development artifacts appear here too: `exec-*` are function executors and
`dev-*` are app dev services. Both scale to zero and cost nothing while idle.
`cycls rm` removes them.
## cycls rm
```bash theme={null}
cycls rm notes # asks for confirmation
cycls rm notes -y # skips confirmation
```
| Flag | Meaning |
| ----------- | ---------------------------- |
| `-y, --yes` | skip the confirmation prompt |
Deleting a deployment detaches its volumes and leaves their data intact.
Redeploying the same name reattaches them.
## cycls logs
```bash theme={null}
cycls logs my-agent
cycls logs my-agent -f
cycls logs my-agent -s 30m
cycls logs my-agent -q 'jsonPayload.error_id="abc12345"'
```
| Flag | Meaning | Default |
| -------------- | ------------------------------------------- | ----------------- |
| `-f, --follow` | tail, polling every 2 seconds | off |
| `-s, --since` | window: `30m`, `24h`, `7d` | most recent batch |
| `-q, --query` | structured filter passed to the log backend | none |
Field reference and examples: [Logs, cost and SQL](/ship/observability).
## cycls cost
```bash theme={null}
cycls cost my-agent
# my-agent $0.027126 (4 turns, 24h)
```
| Flag | Meaning | Default |
| ----------------------- | --------------------------------------------------------- | ------- |
| `-s, --since` | `30m`, `24h`, `7d` | `24h` |
| `-m, --month [YYYY-MM]` | a calendar month, current if no value. Excludes `--since` | off |
| `-b, --by` | group by `user`, `chat` or `model` | none |
Requires `cycls.LLM().price(...)` on the agent. Without it, spend reports as zero.
## cycls sql
```bash theme={null}
cycls sql 'SELECT COUNT(*) FROM logs'
cycls sql -f query.sql
cat query.sql | cycls sql
cycls sql 'SELECT ...' --json
```
| Flag | Meaning | Default |
| ------------ | ---------------------------- | ------------------------------------ |
| `-f, --file` | read the query from a file | none |
| `--format` | `table`, `json` or `csv` | table on a terminal, json when piped |
| `--json` | shortcut for `--format json` | |
Pass `-` as the query to force reading stdin. Tables and example queries:
[Logs, cost and SQL](/ship/observability).
## cycls volume
```bash theme={null}
cycls volume create training-data
cycls volume ls
cycls volume ls training-data models/
cycls volume put training-data ./model.bin models/model.bin
cycls volume get training-data outputs/result.parquet .
cycls volume rm training-data models/old.bin
cycls volume delete training-data -y
```
| Subcommand | Arguments | Purpose |
| ---------- | --------------------- | ---------------------------------------------------- |
| `create` | `name` | create a volume |
| `ls` | `[name] [path]` | list volumes, or contents under a prefix |
| `put` | `name local [remote]` | upload a file. Remote path defaults to the file name |
| `get` | `name remote [local]` | download a file |
| `rm` | `name path` | remove one file |
| `delete` | `name [-y]` | delete the volume and its data |
`put` and `get` talk to storage directly, so transfers do not pass through the
API and file size is effectively unlimited. `delete` refuses while any deployment
has the volume attached and names them in the error. Deleted volumes stay
recoverable for seven days.
## cycls version
```bash theme={null}
cycls version
# cycls 0.0.2.141
```
## Next
A guided build from an empty directory to a deployed URL.
# Deploy
Source: https://docs.cycls.com/ship/deploy
Publish a function, app or agent to a URL, set its size and limits, and understand what survives a redeploy.
This page is for developers taking a working local file to production.
**Prerequisites:** a `CYCLS_API_KEY` from [Cycls Cloud](https://cloud.cycls.com),
set in the environment or in a `.env` file beside your code.
```bash theme={null}
cycls deploy my_agent.py
# [DONE] https://my-agent.cycls.ai
```
## Naming
The deployment name is the decorator's `name=` or the function name, and it
becomes the subdomain:
```python theme={null}
@cycls.agent(name="atlas", web=web, volumes={"/workspace": chats})
async def my_agent(context): ...
# https://atlas.cycls.ai
```
Redeploying the same name updates it in place. Names are checked for availability
before the archive uploads, and the error names the conflict.
To pick one target out of a file with several decorated objects:
```bash theme={null}
cycls deploy file.py::atlas
```
## What deploy produces
Deploy reads the shape of the decorated function:
| Shape | Result |
| -------------------------------- | ------------------------------------------------------ |
| `@cycls.agent` | a chat product at `https://.cycls.ai` |
| `@cycls.app` | an ASGI service at `https://.cycls.ai` |
| `@cycls.function` taking `port` | a server at `https://.cycls.ai` |
| `@cycls.function` with no `port` | a named endpoint, called with `cycls.remote("")` |
## Sizing and limits
```python theme={null}
@cycls.function(
image=image,
cpu=4,
memory="8Gi",
timeout=1800,
concurrency=1,
)
def train(epochs): ...
```
| Argument | Accepted values | Default | Notes |
| ------------- | -------------------- | --------------- | ---------------------------------------------------------- |
| `cpu` | 1, 2, 4, 6, 8 | platform choice | raised automatically if `memory` requires more |
| `memory` | `512Mi` to `32Gi` | `1Gi` | |
| `timeout` | seconds, up to 3600 | 1200 | how long one request may run |
| `concurrency` | integer | high | requests one instance serves at once. Set to 1 for compute |
| `volumes` | mount path to volume | none | required at `/workspace` for agents |
| `schedule` | `cycls.Cron(...)` | none | bare functions only |
Instances scale to zero when idle, so an unused deployment costs nothing and the
next call pays a cold start of a few seconds.
## Environment variables
Values reach the container two ways.
1. Bundle a dotenv file with the image. The SDK loads `.env` automatically at
import.
```python theme={null}
image = cycls.Image().copy(".providers.env", ".env")
```
2. Read `os.environ` inside the function body for values the platform injects,
such as `CYCLS_VOLUMES`.
Keep `CYCLS_API_KEY` out of the image. It is a deploy credential, not a runtime
one. See [Environment variables](/reference/environment) for the full list.
## Calling a deployed endpoint
```python theme={null}
import cycls
pi = cycls.remote("simulate")(10_000_000)
results = cycls.remote("simulate").map([10**6] * 100)
```
Authentication is a token derived as `sha256(api_key : name)`, computed
independently by the deployer and the caller and stored nowhere. Any machine
holding your `CYCLS_API_KEY` can call your deployments, and nothing else can.
Rotating the key strands existing services, because their baked token came from
the old key. Remove and redeploy them after a rotation.
Every call carries its Python and cloudpickle versions. The endpoint refuses a
pickle that cannot cross the boundary and returns an explicit error instead of a
confusing unpickle failure. Redeploy from the calling environment to resolve it.
## Isolation boundary
Deployments under one account share a trust domain. Code running in one can reach
another's workspace storage. For hard isolation, such as production against
experiments or separating clients, deploy from a separate organization. Each
organization is its own tenant with its own boundary.
## Removing a deployment
```bash theme={null}
cycls rm notes # asks for confirmation
cycls rm notes -y # skips it
```
`cycls rm` deletes the deployment and detaches its volumes. Volume data is not
touched, and redeploying the same name reattaches it with files intact. Deleting
data is always explicit:
```bash theme={null}
cycls volume delete notes-data
```
## Deploying from Python
```python theme={null}
my_agent.deploy()
my_app.deploy()
simulate.deploy()
```
Keep these out of module scope. Every command imports your file, so a top-level
`deploy()` fires during `cycls shell` and `cycls deploy` too.
```python theme={null}
if __name__ == "__main__":
my_agent.deploy()
```
## Next
Logs, error references, cost and SQL over both.
# Logs, cost and SQL
Source: https://docs.cycls.com/ship/observability
Find a user's failed request by its reference id, aggregate model spend, and run SQL across logs and billing.
This page is for operators debugging a report from a user, and for developers
tracking spend. Every command here needs `CYCLS_API_KEY` set.
## Find one failed request
When an agent run fails with an unhandled exception, the user sees a short
reference and the full detail goes to structured logs.
```
Something went wrong. Reference: abc12345
```
```bash theme={null}
cycls logs my-agent --query 'jsonPayload.error_id="abc12345"'
```
The structured error record contains:
| Field | Value |
| ---------- | ------------------------------------ |
| `source` | `"agent"` |
| `level` | `"error"` |
| `error_id` | the short hex id shown to the user |
| `message` | the exception text, untruncated |
| `stack` | the full traceback |
| `user_id` | the tenant user, null when anonymous |
| `chat_id` | the chat, null when anonymous |
Errors the loop handles itself, such as a rate limit retry, a failed compaction
or a tool timeout, are not logged as errors. They are handled behavior, not
failures. When one reaches the user as a warning with a reference, that reference
is the `error_id` of a `level=warn` record, so the same query finds it.
## Tail and filter
```bash theme={null}
cycls logs my-agent # the most recent batch, then exit
cycls logs my-agent -f # tail, polling every 2 seconds
cycls logs my-agent -s 30m # a narrower window: 30m, 24h, 7d
cycls logs my-agent -q 'jsonPayload.level="error"'
cycls logs my-agent -q 'jsonPayload.user_id="user_2yY1..."' -f
```
`-q` passes a structured filter to the log backend and is reapplied on every
poll, so it composes with `-f`.
## Aggregate model spend
Each model turn emits a `level=usage` record. `cycls cost` aggregates it.
```bash theme={null}
cycls cost my-agent
# my-agent $0.027126 (4 turns, 24h)
cycls cost my-agent --since 7d
cycls cost my-agent --month 2026-09
cycls cost my-agent --by user # or chat, or model
```
| Flag | Meaning |
| ---------------------------- | ----------------------------------------------------------------------------------- |
| `-s, --since` | `30m`, `24h`, `7d`. Default `24h` |
| `-m, --month [YYYY-MM]` | a calendar month. No value means the current one. Cannot be combined with `--since` |
| `-b, --by user\|chat\|model` | group the rows |
Costs are zero unless the agent sets token prices:
```python theme={null}
llm = cycls.LLM().price(input=3, output=15, cache_read=0.30, cache_write=6)
```
Per-chat totals also persist in the chat index and are returned by `GET /chats`,
so a sidebar can show spend without rereading turns.
## SQL over logs and billing
```bash theme={null}
cycls sql 'SELECT ...' # inline
cycls sql -f query.sql # from a file
cat query.sql | cycls sql # from stdin
```
Output is an aligned table on a terminal and JSON when piped. Use
`--format table|json|csv` to force one. An empty result prints `(0 rows)` to
stderr and exits 0.
Two tables are available, scoped automatically to the deployments your API key
owns.
### `logs`
| Column | Type | Notes |
| ----------------------------------- | ------ | ----------------------------------------------------------------- |
| `timestamp`, `severity`, `log_name` | | |
| `resource.labels` | JSON | `JSON_VALUE(resource.labels, '$.service_name')` is the deployment |
| `json_payload` | JSON | structured SDK records, keyed by `level` |
| `text_payload` | STRING | plain stdout and stderr |
`json_payload` records by `level`:
| `level` | Fields |
| ------------ | ------------------------------------------------------------------------------------------------------------------ |
| `error` | `error_id`, `message`, `stack`, `user_id`, `chat_id` |
| `usage` | `model`, `input`, `output`, `cached`, `cache_create`, `cost`, `ms`, and `compact` on the call that wrote a summary |
| `tool_call` | `tool`, `ms`, `ok`, `output_bytes`, `connector`, `error` |
| `approval` | `tool`, `risk`, `how` |
| `compaction` | `tier` (1 cleared, 2 summarized), `reason` (`trigger`, `cold` or `overflow`), `tokens`, `ok` |
| `warn` | `message`, and `error_id` when the user was shown a reference |
### `billing`
| Column | Type | Notes |
| ---------------------------------------- | --------------- | ----------------------- |
| `usage_start_time`, `usage_end_time` | TIMESTAMP | |
| `service.description`, `sku.description` | STRING | compute service and SKU |
| `cost`, `currency` | FLOAT64, STRING | usually USD |
| `resource.name` | STRING | the deployment name |
### Example queries
```sql theme={null}
-- Model spend per user, last 30 days
SELECT JSON_VALUE(json_payload, '$.user_id') AS user_id,
SUM(CAST(JSON_VALUE(json_payload, '$.cost') AS FLOAT64)) AS spend
FROM logs
WHERE JSON_VALUE(json_payload, '$.level') = 'usage'
AND timestamp > TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 30 DAY)
GROUP BY user_id
ORDER BY spend DESC
```
```sql theme={null}
-- Infrastructure cost per deployment, last 30 days
SELECT resource.name AS deployment, ROUND(SUM(cost), 4) AS usd
FROM billing
WHERE usage_start_time > TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 30 DAY)
GROUP BY deployment
ORDER BY usd DESC
```
```sql theme={null}
-- Tool latency at p95, last 7 days
SELECT JSON_VALUE(json_payload, '$.tool') AS tool,
APPROX_QUANTILES(CAST(JSON_VALUE(json_payload, '$.ms') AS INT64), 100)[OFFSET(95)] AS p95_ms,
COUNT(*) AS calls
FROM logs
WHERE JSON_VALUE(json_payload, '$.level') = 'tool_call'
AND timestamp > TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 7 DAY)
GROUP BY tool
ORDER BY p95_ms DESC
```
```sql theme={null}
-- Compactions per day by tier, and the summaries that failed, last 7 days
SELECT DATE(timestamp) AS day,
JSON_VALUE(json_payload, '$.tier') AS tier,
COUNT(*) AS compactions,
COUNTIF(JSON_VALUE(json_payload, '$.ok') = 'false') AS failed
FROM logs
WHERE JSON_VALUE(json_payload, '$.level') = 'compaction'
AND timestamp > TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 7 DAY)
GROUP BY day, tier
ORDER BY day DESC
```
Known behavior: the first `cycls sql` call after a deployment is created can take
5 to 10 seconds while the backend sets up. A per-query scanned-bytes cap applies,
so add `LIMIT` or narrower predicates on wide scans. SQL engine errors are
surfaced verbatim.
## Emit your own records
```python theme={null}
cycls.log("cap_hit", user=context.user, chat_id=context.chat_id,
kind="user_free_monthly", count=entry["count"])
```
The first argument is the level. `user` and `chat_id` attach attribution, and any
other keyword becomes a field in `json_payload`, queryable with `-q` and `sql`.
## Next
Every command and flag.
# Authentication
Source: https://docs.cycls.com/web/auth
Sign-in is a provider object. Clerk works out of the box, and any OIDC issuer works with a JWKS URL.
Auth providers are first-class objects. Hand one to `cycls.Web().auth(...)` for
agents, or to `@cycls.app(auth=...)` for apps, and the rest follows: sign-in
pages, protected routes, per-user storage and `context.user`.
```python theme={null}
web = cycls.Web().auth(cycls.Clerk())
```
Every provider supports a development and a production configuration, and Cycls
picks the right one based on whether you ran `deploy` or `run`.
## Clerk
The hosted default. No configuration needed to get started.
```python theme={null}
cycls.Clerk()
cycls.Clerk(one_tap=True) # Google One Tap on the signed-out page
cycls.Clerk( # your own Clerk tenant
jwks_url="https://clerk.mycompany.com/.well-known/jwks.json",
dev_jwks_url="https://dev-clerk.mycompany.com/.well-known/jwks.json",
pk="pk_live_...",
dev_pk="pk_test_...",
)
```
`one_tap=True` needs Google enabled in Clerk with custom credentials, and every
agent origin listed in that OAuth client's authorized JavaScript origins.
## Any OIDC provider
```python theme={null}
cycls.JWT(
jwks_url="https://my-prod.auth0.com/.well-known/jwks.json",
dev_jwks_url="https://my-dev.auth0.com/.well-known/jwks.json",
)
```
Works with Auth0, WorkOS, Okta, Supabase, Keycloak and anything else that
publishes a JWKS endpoint. The default mapping takes `sub` as the user id.
## Firebase and GCP Identity Platform
```python theme={null}
cycls.GCP(project_id="my-project")
```
Maps `firebase.tenant` to `org_id`, so multi-tenant projects get the same
workspace semantics as Clerk organizations.
## The user object
```python theme={null}
user = context.user
user.id # stable user id
user.org_id # organization id when acting in one
user.org_slug
user.org_role
user.org_permissions # list
user.plan # subscription tier from the JWT claim
user.features # feature flags
user.name # display name when the JWT carries one
user.image_url # avatar when the JWT carries one
```
`context.user` is `None` when no provider is set. In apps, the same object comes
from the `auth` dependency:
```python theme={null}
@app.get("/me")
async def me(user=my_app.auth):
return {"id": user.id, "plan": user.plan}
```
## Protecting your own routes
```python theme={null}
from fastapi import Depends
@my_agent.server.api_route("/admin/stats", methods=["GET"])
async def stats(user=Depends(my_agent.auth)):
if user.org_role != "admin":
return {"error": "forbidden"}
return await collect_stats()
```
## What auth switches on
Auth switches on more than a login screen. Without a provider, an agent serves an anonymous
chat and the state routers are not installed. With one, you also get:
* per-user chats, files and key-value storage under `/workspace//`
* share links and forking
* [team workspaces](/web/workspaces)
* [connectors](/agents/connectors), since a grant belongs to a person or a team
* plan and feature gating through `user.plan` and `user.features`
## Plans
The Cycls-hosted Clerk app emits values like `u:free_user` and `o:free_org`,
where the prefix says whether the plan is a user plan or an organization plan,
and `cycls_pass` for paid subscribers. Your own tenant can emit anything.
```python theme={null}
if context.user.plan == "cycls_pass":
yield "Premium features are enabled."
else:
yield "Upgrade for full access."
yield {"type": "ui", "action": "open_plan_modal"}
```
See [Monetization](/web/monetization) for quotas, plan gating and in-app
purchase entitlements.
## Next
Personal and team workspaces, and how data is partitioned.
# Interface
Source: https://docs.cycls.com/web/interface
Branding, themes, colors, SEO and the empty-chat screen. Configure the chat product your agent ships with.
`cycls.Web` configures everything the user sees and the identity behind it. Pass
it to `@cycls.agent(web=...)`.
```python theme={null}
web = (
cycls.Web()
.auth(cycls.Clerk())
.title("Atlas")
.brand(name="Atlas", description="Your research assistant", logo="./logo.svg")
.colors(primary="#FF6200", secondary="#1A1A1A")
.suggestions(True)
.analytics(True)
)
```
## Branding
```python theme={null}
web = (
cycls.Web()
.brand(name="Atlas", description="Your research assistant",
logo="./icon.svg", brand="./wordmark.svg",
og="./og.png", favicon="./favicon.svg")
.brand("ar", name="أطلس", description="مساعدك للبحث")
)
```
| Field | Where it shows |
| ------------- | ----------------------------------------------------------- |
| `name` | the chat hero and the title bar |
| `description` | the line under the name on an empty chat |
| `logo` | the agent icon in the hero |
| `brand` | the wordmark in the nav bar, falling back to the Cycls mark |
| `og` | the social card image, global |
| `favicon` | the tab icon, global |
`name`, `description`, `logo` and `brand` are per locale, so call `.brand()`
again with `"ar"` for Arabic. Image paths are read at build time, SVGs inlined
and rasters embedded as data URIs, so assets do not need to ship with the
container.
## Pulling copy from a CMS
```python theme={null}
web = cycls.Web().cms(
brand="https://cms.example.com/agents/atlas",
explore="https://cms.example.com/agents",
connectors="https://cms.example.com/connectors",
token=os.environ["CMS_TOKEN"],
)
```
These are plain GET URLs returning the contract JSON. Anything declared in code
wins field by field, so a `.brand()` call overrides the CMS for exactly that
field and no other.
## Theme and colors
```python theme={null}
web = (
cycls.Web()
.theme("default") # "default" or "dev"
.colors(primary="#FF6200", secondary="#F5F5F5",
primary_dark="#FF8A3D", secondary_dark="#1F1F1F")
)
```
`primary` drives highlights and active states, `secondary` drives chips and
bubbles. The `_dark` variants override dark mode and default to the light values.
## The empty-chat screen
```python theme={null}
web = cycls.Web().suggestions(True)
```
Prompt starters, off by default.
```python theme={null}
web = cycls.Web().examples({
("Data analysis", "تحليل البيانات"): [
"https://atlas.cycls.ai/shared/u_123/abc",
],
"Landing pages": [
"https://atlas.cycls.ai/shared/u_123/def",
{"video": "/public/tour.mp4", "title": "Watch a two minute tour"},
],
})
```
Each entry is either a share URL of a real conversation this agent produced,
which renders as an artifact card with Use prompt and View, or a video, which
renders as a tutorial card. A tuple key gives the category pill both locales.
```python theme={null}
web = cycls.Web().explore(
{"name": "Atlas", "url": "https://atlas.cycls.ai", "logo": "./atlas.svg"},
{"name": "Scout", "url": "https://scout.cycls.ai", "name_ar": "كشاف"},
)
```
The agents dropdown in the header. A static list overrides the CMS list. With
neither, the menu is hidden.
## SEO and social
```python theme={null}
web = (
cycls.Web()
.seo(title="Atlas, the research agent", description="Ask anything about your documents.")
.head('')
)
```
`.seo()` covers the page title and meta description when they should differ from
the brand copy. `.head()` appends raw HTML to `` and is repeatable.
Every agent also serves `/og.png`, a generated social preview, plus
`/robots.txt`, `/sitemap.xml` and `/llms.txt`.
## Static files
```python theme={null}
web = cycls.Web().copy_public("./assets/logo.png", "./downloads/")
```
Served at `https://your-agent.cycls.ai/public/logo.png`.
## Uploads
```python theme={null}
web = cycls.Web().max_upload(256) # MB per file, default 512
```
Enforced server-side and pre-checked in the client so oversized files fail fast.
## Analytics and push
```python theme={null}
web = (
cycls.Web()
.analytics(cycls.PostHog(), cycls.GTM("GTM-ABCD123"))
.notifications(cycls.OneSignal("00000000-0000-0000-0000-000000000000"))
.affiliate(os.environ["REWARDFUL_KEY"])
)
```
`.analytics(True)` is shorthand for the PostHog default. Providers are plugins
over one canonical event pipe, and each can be scoped with an `events` allowlist.
`.notifications()` adds web push, where the permission card is ours and the
trigger is a platform decision.
## Reacting to finished runs
```python theme={null}
async def on_run(record):
if record["status"] == "failed":
await page_oncall(record)
web = cycls.Web().on_run(on_run)
```
Called once when a run reaches any terminal status: done, stopped, interrupted or
failed. Use it for push, email, a webhook or a row in a table. Exceptions are
logged and never reach the run.
## Method reference
| Method | Purpose |
| --------------------------------------------- | ----------------------------------------------------------------------------- |
| `.auth(provider)` | [`cycls.Clerk()`](/web/auth) or `cycls.JWT(...)` |
| `.iap(config)` | [Apple in-app purchase](/web/monetization#apple-in-app-purchase) entitlements |
| `.title(str)` | tab and app title |
| `.brand(locale, ...)` | static branding per locale |
| `.cms(brand=, explore=, connectors=, token=)` | pull copy from a CMS |
| `.theme(name)` | `"default"` or `"dev"` |
| `.colors(...)` | accent colors, light and dark |
| `.seo(title=, description=)` | page and social copy |
| `.head(html)` | append to ``, repeatable |
| `.explore(*agents)` | the agents dropdown |
| `.examples(shares)` | the example gallery |
| `.suggestions(bool)` | prompt starters |
| `.analytics(*providers)` | PostHog, GTM, or `True` for the default |
| `.notifications(*providers)` | web push plugins |
| `.affiliate(key)` | referral tracking |
| `.max_upload(mb)` | per-file upload cap |
| `.copy_public(*files)` | files served at `/public` |
| `.workspaces(create=)` | [multi-workspace mode](/web/workspaces) |
| `.connectors(*objs)` | [connector directory](/agents/connectors) |
| `.on_run(fn)` | callback when a run ends |
## Next
Clerk, any OIDC provider, and what lands in `context.user`.
# Plans and quotas
Source: https://docs.cycls.com/web/monetization
Read the subscription tier from context.user.plan, meter usage on the workspace volume, and verify Apple in-app purchases offline.
This page is for developers adding paid tiers to an agent. It covers reading the
plan claim, enforcing a quota, and accepting an Apple in-app purchase as proof of
entitlement.
**Prerequisites:** an auth provider configured with
[`cycls.Web().auth(...)`](/web/auth), and a volume at `/workspace`.
## Read the plan
`context.user.plan` is whatever your identity provider puts in the JWT claim. The
Cycls-hosted Clerk application emits:
| Value | Meaning |
| ------------- | ----------------- |
| `u:free_user` | free individual |
| `o:free_org` | free organization |
| `cycls_pass` | paid subscriber |
```python theme={null}
if context.user.plan == "cycls_pass":
yield "Premium features are enabled."
else:
yield "Upgrade for full access."
yield {"type": "ui", "action": "open_plan_modal"}
```
`{"type": "ui", "action": "open_plan_modal"}` opens the pricing modal. The client
selects user plans or organization plans based on the active organization.
## Meter usage per user
Store the counter in `cycls.DB`, which writes to the `/workspace` volume. Keying
by month means history accumulates and resets happen without a scheduled job.
```python theme={null}
from datetime import datetime, timezone
import cycls
FREE_MONTHLY_LIMIT = 10
@cycls.agent(image=image, web=web, volumes={"/workspace": cycls.Volume("my-agent")})
async def my_agent(context):
user = context.user
exempt = not context.prod # local runs are never blocked
if user.plan == "o:free_org" and not exempt:
cycls.log("cap_hit", user=user, chat_id=context.chat_id, kind="org_free")
yield {"type": "callout", "callout": "This workspace needs a paid plan.", "style": "error"}
yield {"type": "ui", "action": "open_plan_modal"}
return
db = cycls.DB(context.workspace)
month = datetime.now(timezone.utc).strftime("%Y-%m")
entry = await db.get(f"usage/{month}", {"count": 0})
if user.plan == "u:free_user" and entry["count"] >= FREE_MONTHLY_LIMIT and not exempt:
cycls.log("cap_hit", user=user, chat_id=context.chat_id,
kind="user_free_monthly", count=entry["count"])
yield {"type": "callout",
"callout": f"Free tier limit reached ({FREE_MONTHLY_LIMIT} per month).",
"style": "warning"}
yield {"type": "ui", "action": "open_plan_modal"}
return
entry["count"] += 1
await db.put(f"usage/{month}", entry)
async for ev in llm.run(context=context):
yield ev
```
Expected behavior: a free user on their eleventh request in a calendar month
sees the warning callout and the pricing modal, and the model is never called.
`cycls.DB` writes are atomic per key, not transactional across keys. Two requests
racing on the same counter can both read the same value. For strict enforcement,
check the counter again after incrementing, or accept a small overshoot.
## Track spend
Set token prices so every turn logs its cost:
```python theme={null}
llm = cycls.LLM().price(input=3, output=15, cache_read=0.30, cache_write=6)
```
Prices are USD per million tokens. Query the result with
[`cycls cost` and `cycls sql`](/ship/observability). Without `.price()`, costs
are logged as zero.
## Apple in-app purchase
Use this when an iOS client sells the subscription and the agent has to trust it
without calling Apple on every request.
```python theme={null}
iap = cycls.AppleIAP(
bundle_id="com.example.app",
products={"com.example.app.pro.monthly": "u:pro"},
namespace="00000000-0000-0000-0000-000000000000",
)
web = cycls.Web().auth(cycls.Clerk()).iap(iap)
```
| Argument | Required | Meaning |
| ----------- | -------- | ----------------------------------------------------------------- |
| `bundle_id` | yes | the app's bundle identifier, checked against the transaction |
| `products` | yes | a set of product ids, or a mapping of product id to plan value |
| `namespace` | yes | UUIDv5 namespace the client also uses to derive `appAccountToken` |
| `plan` | no | plan value for set-form products, default `u:iap` |
| `header` | no | request header carrying the JWS, default `x-apple-entitlement` |
| `root_cert` | no | override the bundled Apple Root CA G3 |
How verification works: the iOS client sends its current StoreKit 2 signed
transaction in the header. Cycls validates the certificate chain against the
bundled Apple Root CA G3, checks the product and expiry, and confirms that the
purchase's `appAccountToken` derives from the authenticated user id under your
namespace. A valid entitlement raises `user.plan` for that request only.
The `appAccountToken` binding is what stops a valid receipt from one account
being replayed by another. Both client and server must derive it with the same
UUIDv5 namespace.
## Affiliate tracking
```python theme={null}
web = cycls.Web().affiliate(os.environ["REWARDFUL_KEY"])
```
The key is injected into the page config. The client loads the tracker and
reports conversions at checkout.
## Next
Where a build happens, and which code it contains.
# Workspaces
Source: https://docs.cycls.com/web/workspaces
Give every user a private workspace and let teams share one, with role-based access and full isolation of files, chats and skills.
Multi-workspace mode gives every user a private personal workspace and lets
members of an organization share team workspaces. Each workspace is a complete
context: its own files, chats, `AGENT.md`, skills and key-value store.
```python theme={null}
web = (
cycls.Web()
.auth(cycls.Clerk())
.workspaces() # anyone in the org can create teams
)
web = cycls.Web().auth(cycls.Clerk()).workspaces(create="admin")
```
It requires auth, since workspaces are keyed on the authenticated user.
## The model
| Workspace | Id | Who can enter | Managed by |
| --------- | ------------- | ------------------------------------------------------- | ---------------------------------- |
| Personal | `u-{user_id}` | the owner only. Org admins get lifecycle, never content | the owner |
| Team | `t-{id}` | members on the ACL, plus org admins implicitly | the creator and any `admin` member |
| General | `t-shared` | every org member as editor, minus anyone excluded | org admins |
Every organization gets **General** on its first request. Solo users without an
organization get a personal workspace only.
Team roles are `owner`, who created it and can delete it, `admin`, who manages
members and renaming, and `editor`, who works in the workspace. The owner row
cannot be changed, and members can always remove themselves.
## Selecting one
The client sends the active workspace in an `X-Workspace` header on every
request. No header means the personal workspace, so existing API clients keep
working unchanged.
```bash theme={null}
curl https://my-agent.cycls.ai/files \
-H "Authorization: Bearer $TOKEN" \
-H "X-Workspace: t-abc123"
```
Unknown or unauthorized ids return 404 rather than 403, so ids do not leak
existence.
## Inside the agent
`context.workspace` resolves to the active workspace. The bash sandbox binds only
that workspace's directory at `/workspace`, and uploads, the canvas, `AGENT.md`
and skills all follow it.
Chats and the `database` tool stay per user within a workspace. Teammates share
files, not each other's conversations.
## HTTP API
Mounted only when workspaces are enabled. All routes require auth.
| Method | Path | Purpose |
| -------- | ------------------------------------ | ---------------------------------------------------------------------- |
| `GET` | `/workspaces` | personal plus your teams. `?all=1` gives org admins the lifecycle view |
| `POST` | `/workspaces` | create a team, body `{"name": ..., "icon"?: "🚀"}` |
| `PATCH` | `/workspaces/{id}` | rename or set the icon, one emoji, owner or admin |
| `DELETE` | `/workspaces/{id}` | owner or org admin |
| `GET` | `/workspaces/{id}/members` | members, or exclusions on General |
| `PUT` | `/workspaces/{id}/members/{user_id}` | body `{"role": "editor" \| "admin"}` |
| `DELETE` | `/workspaces/{id}/members/{user_id}` | managers, or yourself to leave |
Adding a member takes their user id from the identity provider. The row alone
grants nothing: access also needs a valid token for the same organization, so a
stray id is inert.
## Turning it on for an existing deployment
The SDK moves no data. On an organization's first request it creates General's
registry row and nothing else. Existing content under the organization root stays
where it is and is invisible in workspace mode until an operator migrates it.
Solo accounts need no migration at all, because their personal workspace is the
account root.
## Security notes
The bash sandbox binds only the active workspace at `/workspace`. Sibling
workspaces are not mounted. Registry and ACL rows live outside every workspace
root, out of reach of both the sandbox and the path-validated file tools.
`AGENT.md` and `skills/` are per workspace, so in a team any editor's
instructions run in teammates' sessions. It is the same trust boundary as a
shared repository, and worth saying out loud to your users.
A deliberately adversarial user with network-enabled bash can reach the
deployment's own credentials. `.sandbox(network=False)` closes that path.
## Next
Plans, quotas, and in-app purchase entitlements.