# Agent workflows

> What DuckLocal 0.4.0 added for AI agents: restricted SQL, a checked handoff to the window, dashboard proposals, analysis records and portable projects.

Source: https://ducklocal.app/docs/agent-workflows

**[中文](zh/agent-workflows.md)**

::: info New in 0.4.0
Everything on this page shipped in [v0.4.0](https://github.com/JetSquirrel/DuckLocal/releases/tag/v0.4.0). If `ducklocal check --help` doesn't list `--restricted`, your copy is older; [update](getting-started.md) to use it.
:::

An agent can already query files, build a dashboard and read your comments with the [CLI](cli.md). These additions cover what happens around that work: limiting SQL it did not write, knowing what the window did with a request, letting you approve a change, and recording how an answer was reached.

## Limit SQL you did not write {#restricted}

`--restricted` is for SQL copied from data, and for a dashboard someone else wrote. Read-only mode alone doesn't cover this: it protects the database file, while `COPY` can still write anywhere you can and `FROM 'https://...'` can still send a request.

```bash
ducklocal query --restricted --allow-read ./data --sql "SELECT * FROM 'data/sales.csv'"
ducklocal check report.dash --restricted --allow-read ./data
```

| The SQL tries to | Result |
| --- | --- |
| Read a file under `--allow-read` | Allowed |
| Read any other file, by path, glob or `..` | Denied |
| Write a file, even under `--allow-read` | Denied |
| `ATTACH` a database, or create one | Denied |
| Reach `http`, `https`, `s3` or any other URL | Denied |
| `INSTALL` or `LOAD` an extension | Denied |
| Change a setting, including these limits | Denied |
| Run longer than `--timeout` (default 60 seconds) | Interrupted, kind `timeout` |
| Use more than `--memory-limit` (default `2GB`) | Fails, kind `resource_limit` |

With no `--allow-read`, no files are readable, and that includes a dashboard's own folder. A denied statement exits 1 with kind `policy_denied` and never falls back to running unrestricted. `EXPLAIN`, `PRAGMA` and a top-level `PIVOT` are refused too, because the statement must be one SELECT.

**This is not a sandbox.** The limits are DuckDB's own, and DuckLocal applies no operating-system isolation on any platform. They bind the SQL, not whoever chooses the flags: an agent with a shell can drop `--restricted`. To contain an agent you don't trust, run the agent itself in a container or VM. `schema`, `profile`, `project check` and `open --run` don't run restricted, and neither does the window when it opens a dashboard.

[What limits agent-run SQL](https://github.com/JetSquirrel/DuckLocal/blob/main/docs/agent-security.md) has the full threat model and what was tested on each platform.

## Stay in step with the window {#handoff}

`ducklocal open` used to report delivery and nothing more. Two flags now report how a request ended, and guard against acting on a window that has changed.

```bash
ducklocal open --state --active                  # note `revision`
ducklocal open --if-revision REV --run --wait \
  --title "Revenue by channel" --sql "SELECT ..."
```

- `--wait` waits up to 5 minutes and adds `status`, `tab_id`, `result_status`, `error` and `revision` to the output.
- `--if-revision REV` opens nothing if the window no longer shows that revision, and exits 1 with kind `stale`. Read the state again before trying again. A person typing in another tab also makes it stale, on purpose.

`--state` now also reports:

| Field | Meaning |
| --- | --- |
| `schema_version`, `revision` | The contract's version, and a token for what the window shows |
| `actions` | What became of recent requests: `stale`, `queued`, `running`, `opened`, `succeeded` or `failed` |
| `limits` | The bounds on previews. A read never returns a whole result |
| `connections` | S3's endpoint and region, and each attached database's name, kind, location and address. **Never a key** |
| `result.reach` | `local`, `remote` or `unknown`, read from the SQL alone, and whether it used your S3 key |
| `sql_withheld` | `true` when a tab's SQL may hold a credential, and the text is replaced |
| `scope`, on each plot | How much of its query the plot draws |

`reach` can only see what the SQL names, so a local view over remote data reads as `local`. Reading any of this makes no network request, so `location: "remote"` says where a database is, not that it still answers.

An agent can see that S3 is configured without seeing the key, but SQL it runs with `open --run` can still use that key.

The [handoff contract](https://github.com/JetSquirrel/DuckLocal/blob/main/docs/agent-handoff.md) lists every field and error kind, and the [JSON schema](https://github.com/JetSquirrel/DuckLocal/blob/main/docs/agent-state-v1.schema.json) describes the state.

## Propose a dashboard change {#propose}

When you want to approve a change, an agent can propose it instead of editing the file:

```bash
ducklocal propose sales.dash --status                  # note current_hash
ducklocal propose sales.dash --from new.dash --note "Split revenue by region" --based-on HASH
ducklocal propose sales.dash --status                  # pending, applied or rejected
```

The spec stays as it is, and the dashboard tab shows that a change is waiting. Click **Review** to run the proposed SQL and see the diff and checks, then click **Apply** or **Reject**.

- Only you can apply a proposal, in the window. There is no command for it.
- Applying keeps the old version as `sales.dash.bak`, and is refused if the spec changed since the proposal.
- Rejecting leaves the spec untouched.
- Clicking **Review** runs the proposed SQL, so a proposal you don't trust can reach whatever the dashboard's SQL can. Run `check --restricted` on it first.

## Record how an analysis got here {#session}

Set `DUCKLOCAL_SESSION`, and `query`, `check`, `propose` and `open` each record what they did:

```bash
export DUCKLOCAL_SESSION=analysis.session.jsonl
ducklocal session note --text "EU revenue fell 12%" --refs 3,4
ducklocal session show --last 20
```

Nothing is recorded without that variable. The file holds SQL and spec text, each source's size and modification time, and each result's shape, never its rows. Errors are kept up to 500 characters and can quote values, so keep the file out of shared folders unless you mean to share them.

Each step and conclusion has `doubts`, worked out when you read the file: what failed, was cut off or has changed since. An empty list means nothing is known against it, not that it is right.

SQL that may hold a credential is left out. That check goes by words such as `secret`, `token` and `access_key`, so a column with one of those names hides its query too.

## Keep an analysis movable {#project}

```bash
ducklocal project init             # write ducklocal-project.json
ducklocal project check            # what a move would break
ducklocal project relink --source orders --to data/2026/orders.csv
```

A `ducklocal-project.json` is optional. It lists a folder's dashboards and what they need; data is referenced, never copied. Opening it in the window opens those dashboards.

`project check` reports `ok`, `portable`, `problems` (each with a `hint`) and `limitations`, and exits 0 whatever it finds, so read the output. It never reaches the network or installs anything. Passing doesn't prove the data is the same. `project relink` changes your dashboards and keeps each old one as `NAME.dash.bak`.

## A worked example {#worked-example}

[A leaked key, investigated by an agent and a person](https://github.com/JetSquirrel/DuckLocal/tree/main/examples/cloudtrail_investigation) walks a made-up week of CloudTrail from the first query to a reviewed dashboard. A test runs the agent's half on every change. Nobody has yet clicked through the window's half of this example, and its README says which steps those are.
