> ## Documentation Index
> Fetch the complete documentation index at: https://docs.cycls.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Build your 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`

<Steps>
  <Step title="Set up the directory">
    ```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
    ```
  </Step>

  <Step title="Write the agent">
    ```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.
  </Step>

  <Step title="Run it">
    ```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.
  </Step>

  <Step title="Add tools">
    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.
  </Step>

  <Step title="Brand it">
    ```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.
  </Step>

  <Step title="Track cost">
    ```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.
  </Step>

  <Step title="Limit the free tier">
    ```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.
  </Step>

  <Step title="Deploy">
    ```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
    ```
  </Step>
</Steps>

## 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

<CardGroup cols={2}>
  <Card title="Add a custom tool" icon="wrench" href="/guides/custom-tool">
    Call your own API from the agent.
  </Card>

  <Card title="Query a data warehouse" icon="database" href="/guides/duckdb-warehouse">
    Parquet on a volume, read with DuckDB.
  </Card>
</CardGroup>
