# Abstra AI Agents — Examples

Worked, annotated examples covering custom function tools, each built-in toolkit, multimodal input, workflow routing, and combined patterns. Patterns are deliberately small — combine them as needed.

---

## Example 1: Custom function tool

**Patterns:** type hints + docstring contract, narrow toolset, plain return value.

```python
from abstra.ai import run_agent
from abstra.tables import select


def lookup_order(order_id: str) -> dict:
    """Return the order with the given ID, including status, customer, and total. Raises ValueError if the order does not exist."""
    rows = select("orders", where={"id": order_id})
    if not rows:
        raise ValueError(f"Order {order_id} not found")
    return rows[0]


result = run_agent(
    prompt="Check order ORD-1234 and explain the current status in one sentence.",
    tools=[lookup_order],
    max_steps=5,
)
print(result)
```

**Why this works:**
- One tool, one task — the agent has no choice to wander.
- The docstring tells the agent *when* to call (look up an order) and *what fails* (ValueError on missing) so it can recover.
- `max_steps=5` matches the task shape: one lookup + one summarization.

---

## Example 2: Files + Tables + Connectors combined

**Patterns:** CSV → DB → Slack notification, narrow scope per toolkit.

```python
from abstra.ai import run_agent, FilesTools, TablesTools, ConnectorsTools

run_agent(
    prompt=(
        "Read complaints.csv, categorize each row as 'billing', 'shipping', or 'product', "
        "insert each into the complaints table with the category, and send a Slack summary "
        "to the #support channel with totals per category."
    ),
    tools=[
        FilesTools(actions=["read_text"], globs=["complaints.csv"]),
        TablesTools(method=["insert"], table="complaints"),
        ConnectorsTools(connection="slack", action="send_message"),
    ],
    max_steps=20,
)
```

**Why each toolkit is scoped tightly:**
- `FilesTools` is read-only and locked to a single file — the agent can't accidentally write or read other files.
- `TablesTools` is insert-only and locked to `complaints` — no accidental updates/deletes.
- `ConnectorsTools` is restricted to `slack.send_message` only — the agent can't browse other actions.

**Footgun avoided:** `globs=["complaints.csv"]` is NOT recursive. If the CSV lived at `data/2024/complaints.csv`, the agent couldn't read it. Use `globs=["**/complaints.csv"]` for that case.

---

## Example 3: Multi-tenant scoping with `where`

**Patterns:** `where` as a scoping invariant, agent cannot cross tenants.

```python
from abstra.ai import run_agent, TablesTools
from abstra.tasks import get_trigger_task

task = get_trigger_task()
tenant_id = task.payload["tenant_id"]

run_agent(
    prompt=f"Find all unpaid invoices and email a reminder to each customer.",
    tools=[
        TablesTools(
            method=["select", "update"],
            table=["invoices", "customers"],
            where={"tenant_id": tenant_id},
        ),
    ],
    max_steps=15,
)
```

**The scoping is enforced regardless of what the agent does:**
- `select("invoices", where={"status": "unpaid"})` becomes `select("invoices", where={"status": "unpaid", "tenant_id": tenant_id})` — toolkit `where` always merged in last.
- If the agent inserts (it can't here — method is restricted), the row would auto-carry `tenant_id`.
- The agent cannot pass `where={"tenant_id": "other"}` and override the scope. The toolkit's value wins.

This is the standard pattern for multi-tenant SaaS.

---

## Example 4: Workflow routing with custom tools

**Patterns:** custom functions that drive workflow transitions, agent makes the routing decision.

```python
from abstra.ai import run_agent, FilesTools
from abstra.tasks import get_trigger_task, send_task

task = get_trigger_task()
file_path = task.payload["document_path"]


def approve(summary: str) -> str:
    """Approve the document and forward to the next stage. Use only when all required fields are present and the document is internally consistent."""
    send_task("approved", {"document_path": file_path, "summary": summary})
    return "Document approved and forwarded."


def reject(missing_fields: str) -> str:
    """Reject the document and report what is missing. `missing_fields` should be a comma-separated list of the missing field names."""
    send_task("rejected", {"document_path": file_path, "missing_fields": missing_fields})
    return f"Document rejected. Missing: {missing_fields}"


run_agent(
    prompt=(
        f"Review the document at {file_path}. Required fields: customer name, "
        "tax ID, total amount, and issue date. Approve if all present and consistent; "
        "reject otherwise."
    ),
    tools=[
        FilesTools(actions=["read_text"], globs=[file_path]),
        approve,
        reject,
    ],
    max_steps=10,
)
```

**Key choices:**
- The agent picks the routing decision — `approve` or `reject` — based on what it found in the document.
- Each function's docstring says when to call it. The agent uses these to decide.
- Returning a string from the function gives the agent something to summarize back to the caller.

---

## Example 5: Multimodal — extract from receipt image

**Patterns:** files inline in prompt, structured extraction, insert-only Tables scope.

```python
from abstra.ai import run_agent, TablesTools
from abstra.tasks import get_trigger_task

task = get_trigger_task()

run_agent(
    prompt=[
        "Extract the store name, date (YYYY-MM-DD), total in cents, and itemized lines "
        "from this receipt. Insert one row into the receipts table with columns: "
        "store_name, purchase_date, total_cents, items (as a JSON-encoded list).",
        task.payload["receipt_image"],  # Path to the image file
    ],
    tools=[TablesTools(method=["insert"], table="receipts")],
    max_steps=5,
)
```

**When to use this vs `prompt()` or `parse_boleto`/`parse_nfe`:**
- `prompt(format=...)` is simpler, cheaper and more predictable when you just need structured data extracted with a fixed schema — prefer it, doing any follow-up (like the insert here) in plain Python.
- `parse_boleto` / `parse_nfe` / `parse_nfse` / `parse_bank_statement` are faster and cheaper for those specific Brazilian formats — use them when applicable.
- `run_agent` is the right choice when the document format is unstructured/varied and the follow-up action depends on what the model finds.
- For text-heavy documents, use `extract_text` + `prompt()` on the text instead of passing the file directly; images like this receipt are where inline files shine.

---

## Example 6: Browser automation — login, navigate, scrape

**Patterns:** `navigate_to_url`, `get_page_summary` first, index-based interaction, screenshot for debugging.

```python
from abstra.ai import run_agent, BrowserTools

with BrowserTools(headless=True) as browser:
    run_agent(
        prompt=(
            "Go to https://example.com/login, sign in with username 'demo' and "
            "password 'secret', then go to the dashboard, find the 'Pending invoices' "
            "section, and return the number shown there."
        ),
        tools=[browser],
        max_steps=15,
    )
```

**Why the `with` block:** `BrowserTools` is a context manager. Without `with`, Playwright resources leak — the next agent run may fail or hang.

**Behavioral expectations:**
- The agent will call `navigate_to_url` → `get_page_summary` → `fill_element(..., username)` → `fill_element(..., password)` → `click_element(submit)` → `get_page_summary` (DOM changed!) → `get_text` for the count.
- If the agent uses a selector that's stale, the tool error explicitly hints at `click_element(index)` from a fresh `get_page_summary` — the agent recovers.

---

## Example 7: Browser — download a file and upload to another site

**Patterns:** `download_url` over `download_file`, persistent path → `upload_file`.

```python
from abstra.ai import run_agent, BrowserTools

with BrowserTools(headless=True) as browser:
    run_agent(
        prompt=(
            "Open https://invoices.example.com/2024/INV-001.pdf and download it. "
            "Then go to https://archive.example.com/upload, click 'Choose file', "
            "and upload the downloaded PDF. Confirm the upload succeeded."
        ),
        tools=[browser],
        max_steps=10,
    )
```

**Why `download_url` is the right choice here:**
- The URL is known up front (`https://invoices.example.com/2024/INV-001.pdf`).
- `download_url` uses the browser's authenticated session, so cookies are preserved.
- It saves to the persistent dir by default, so the `path` it returns can be fed straight into `upload_file`.

**When would you use `download_file` instead?**
- The download URL is only revealed after a click (Blob URLs created via JavaScript).
- The download is the result of a POST or form submission, not a GET.
- `Content-Disposition: attachment` is set by a click handler, not a static URL.

---

## Example 8: Heavy browser automation — listen to network requests

**Patterns:** `listen_network=True` to capture XHR/fetch, then introspect with `get_network_requests`.

```python
from abstra.ai import run_agent, BrowserTools

with BrowserTools(listen_network=True, headless=True) as browser:
    run_agent(
        prompt=(
            "Open https://dashboard.example.com/reports, click 'Generate report', "
            "wait for the report to finish loading, then return the URL of the "
            "JSON API call that returned the report data (look for /api/reports/)."
        ),
        tools=[browser],
        max_steps=15,
    )
```

**When `listen_network` is worth it:**
- Reverse-engineering an SPA that loads data via XHR.
- Capturing auth tokens or signed URLs from API responses.
- Debugging "the data is on screen but I can't find a selector for it" — often the underlying API is easier to read.

---

## Example 9: Locking a connector parameter via a wrapper function

**Patterns:** `ConnectorsTools.params` is for *defaulting* (agent can override). When you need to LOCK a parameter, wrap it in a custom function.

**Wrong — agent can override:**
```python
from abstra.ai import run_agent, ConnectorsTools

run_agent(
    prompt="Notify the team that the deploy finished.",
    tools=[
        ConnectorsTools(
            connection="slack",
            action="send_message",
            params={"channel": "C0DEPLOY"},  # the agent CAN pass a different channel
        )
    ],
)
```

**Right — channel is locked:**
```python
from abstra.ai import run_agent
from abstra.connectors import run_connection_action


def notify_deploy_channel(text: str) -> dict:
    """Post a message to the team's deploy channel. `text` is the message body."""
    return run_connection_action("slack", "send_message", {"channel": "C0DEPLOY", "text": text})


run_agent(
    prompt="Notify the team that the deploy finished.",
    tools=[notify_deploy_channel],
)
```

The wrapper hides `channel` entirely — the agent's only choice is the message body.

---

## Example 10: Combining everything (a realistic pipeline)

**Patterns:** narrow toolsets, scoping `where`, custom function for the routing decision, `max_steps` tuned to the task.

```python
from abstra.ai import run_agent, FilesTools, TablesTools, ConnectorsTools
from abstra.tasks import get_trigger_task, send_task

task = get_trigger_task()
tenant_id = task.payload["tenant_id"]


def escalate_to_human(reason: str) -> str:
    """Mark the document for manual review. Use when the document is ambiguous, unreadable, or contradicts existing data."""
    send_task("manual_review", {"document_id": task.payload["document_id"], "reason": reason})
    return "Escalated for manual review."


run_agent(
    prompt=[
        "This is an invoice for our tenant. Extract: vendor name, invoice number, "
        "total in cents, due date (YYYY-MM-DD). Cross-check against the vendors table — "
        "the vendor name must exist there. If it does, insert into the invoices table "
        "and post a confirmation to #ap-confirmations. If the vendor is unknown or "
        "the document is unreadable, call escalate_to_human with a clear reason.",
        task.payload["document_path"],
    ],
    tools=[
        FilesTools(actions=["read_text"], globs=[task.payload["document_path"]]),
        TablesTools(
            method=["select", "insert"],
            table=["vendors", "invoices"],
            where={"tenant_id": tenant_id},
        ),
        ConnectorsTools(connection="slack", action="send_message", params={"channel": "C0APCONF"}),
        escalate_to_human,
    ],
    max_steps=20,
)
```

**Why this is a good agent task:**
- The next action depends on whether the vendor exists — plain Python would need many branches; the agent handles it.
- Multi-tenancy is enforced via `where={"tenant_id": ...}` regardless of what the agent does.
- The "fail safely" path (`escalate_to_human`) is a tool the agent calls explicitly — the workflow always reaches a terminal state.
- Each toolkit is narrowly scoped; the agent has limited surface area to make wrong calls.
