# AI CLI and official skill

> Use the DuckLocal DuckDB SQL CLI with AI agents: inspect local file schemas, run one statement, and return bounded JSON without a GUI or server.

Source: https://ducklocal.app/docs/cli

**[中文](zh/cli.md)**

For the task-oriented overview, see [SQL for AI agents on local data](https://ducklocal.app/use-cases/ai-agent-sql/). This page is the complete command and output reference.

## Run without a window

`ducklocal --help` lists nine subcommands:

| Command | Does |
| --- | --- |
| `ducklocal query` | Run one SQL statement and print the result as JSON |
| `ducklocal schema` | List what there is to query, with columns; `--stats` for one relation's column statistics |
| `ducklocal open` | Show SQL, a dashboard or data in the running window; `--state` reads back what it shows |
| `ducklocal check` | Validate a `.dash` dashboard |
| `ducklocal comments` | Read and answer the review comments people left on a dashboard |
| `ducklocal propose` | Suggest a dashboard change for the person to review and apply |
| `ducklocal project` | Check a portable analysis folder and what it needs; `relink` fixes moved paths |
| `ducklocal session` | Read back how an analysis got where it did |
| `ducklocal presence` | Show who is working on an open dashboard and where an edit will land |

`ducklocal lsp`, the language server that editors spawn for `.dash` files, and `ducklocal profile`, the legacy alias for `schema --stats`, still work but are omitted from `--help`.

`propose`, `project` and `session`, and the `--restricted`, `--wait` and `--if-revision` options, arrived in 0.4.0 and are covered in [Agent workflows](agent-workflows.md).

`ducklocal query` executes SQL without initializing the GUI, history, registered files, language settings, or the GUI global connection. Each invocation creates a dedicated connection. Invoking DuckLocal without commands—or only with file paths, directories, and glob patterns—opens the regular GUI. To open a GUI path whose name matches a command (`query`, `schema`, `open`, `check`, `comments`, `propose`, `project`, `session`, `presence`, `profile`, or `lsp`), prefix it with `./`, such as `./query`.

## Get the command {#get-the-command}

DuckLocal supports macOS 12+ on Apple silicon, Windows (x86_64), and Linux (x86_64). The CLI runs directly from the application executable, so you do not need to install a separate DuckDB CLI binary.

The quickest way onto your PATH is from the app (0.1.1 and later): open **Command line & AI…** — the robot button in the title bar, or the DuckLocal menu — and click **Install command**. The same dialog installs the skill for AI agents; see [Install the official skill](#install-the-official-skill). If you skip the install, call the executable where it is:

```bash
# macOS
/Applications/DuckLocal.app/Contents/MacOS/ducklocal --help
# Linux: wherever you extracted the tarball
./ducklocal-linux-x86_64/ducklocal --help
# Windows (PowerShell): wherever you extracted the zip
.\ducklocal-windows-x86_64\ducklocal.exe --help
```

From a source checkout, `cargo build --locked` builds `./target/debug/ducklocal`. The examples below assume `ducklocal` is on your PATH. Older releases may not have `query`; check `--help` first. JSON and Parquet support are bundled, not downloaded at query time.

## Query options

```bash
ducklocal --help
ducklocal --version
ducklocal query --help
ducklocal query --sql "SELECT 1 AS n"
ducklocal query --sql "SELECT 1 AS n" --format md
ducklocal query --sql-file analysis.sql --limit 100
ducklocal query --sql-file - < analysis.sql
ducklocal query --database warehouse.duckdb --sql "SHOW TABLES"
```

- Exactly one of `--sql SQL` and `--sql-file FILE` is required. Files must be UTF-8; `-` reads stdin. SQL must contain exactly one statement. DuckDB's parser validates this before opening the target database or executing anything; comments, quoted semicolons, and trailing semicolons work. Scripts are rejected, not partially executed.
- `--database PATH` opens an **existing file, read-only by default**. Omit it for a fresh in-memory database. `--read-write` requires `--database` and explicitly permits writes and creating a database (not parent directories). Database errors never fall back to memory. When DuckDB refuses a file, the error message starts with the reason DuckLocal reads from the file's header — written by a newer DuckDB, a v0.9-era file, a SQLite file, or not a database at all — and what to run instead (for a newer file, the `ATTACH … (STORAGE_VERSION)` / `COPY FROM DATABASE` copy to make in that release; for SQLite, the `ATTACH` that reads it), ahead of DuckDB's own message.
- `--limit N` is a positive integer, defaults to 1000, and caps returned rows. A 2,000,000-cell budget also applies. After the cap, the CLI keeps counting up to 100,000 more rows without encoding their values, reporting `total_rows` and `total_rows_exact`. This limits output, not computation or memory: sorting and aggregates can still process the whole input. An execution error encountered after the output cap still fails the command.
- `--format json` (the default) is the contract below. `--format md` renders the same result as a Markdown table, for a document or an agent reading it. Nothing else changes: the same SQL, the same limits, the same errors.
- Repeated/unknown flags, missing values, empty SQL, and invalid limits are errors. Options use separate values, not `--flag=value`. Relative paths resolve against the process working directory, not the SQL file directory. Quote shell paths; inside SQL, double apostrophes (`'O''Brien.csv'`). SQL identifiers use double quotes.

**Read-only is not a filesystem or network sandbox.** SQL such as `COPY` can write files even when the database is read-only. Authorize output paths, overwrites, database mutations, extension installation, and external access before execution. DuckLocal does not automatically install extensions or retry operations. Explicit `INSTALL`/`LOAD` SQL remains possible with authorization; installed extensions may autoload. A failed command can already have performed side effects (for example before an output failure); inspect the destination before retrying.

### Markdown output

`--format md` is a rendering, not a second contract: the values are the ones the JSON holds, written the way the results grid shows them.

| Value | Rendered as |
| --- | --- |
| `NULL` | `NULL` |
| integer, decimal, float | the digits the value carries, exactly — a `DECIMAL(38,10)` keeps its scale, `NaN`/`inf`/`-inf` are spelled out |
| `DATE` | `YYYY-MM-DD` |
| `TIMESTAMP` | `YYYY-MM-DD HH:MM:SS[.fffffffff]`, with the fraction only when the value has one, and as many digits as it has. No timezone is appended; `--format json` keeps the raw count |
| `TIME` | `HH:MM:SS[.ffffff]` |
| `INTERVAL` | as DuckDB writes it: `1 month 2 days 00:00:00.000003` |
| `BLOB`, `GEOMETRY` | `0x00ff`, elided with `…` when long |
| `LIST`, `STRUCT`, `MAP` | `[1, 2]`, `{x: 1}`, elided with `…` past 120 characters |

A `|` in a cell is escaped and a newline becomes `<br>`, so a value cannot break the table it is in. A result with no rows is followed by `_0 rows._`, and a result that was cut short by `--limit` or the cell budget by `_Truncated at 2 of 5000 rows, so this is not the whole answer._` (`of at least …` past the 100,000-row count) — a preview should not read like a complete answer. Errors are unchanged: JSON on stderr, empty stdout.

## Map what can be queried {#map-what-can-be-queried}

`ducklocal schema` answers "what is there, and what are its columns?" in one call, where `DESCRIBE` answers it one relation at a time. It is the first thing an agent should run.

```bash
ducklocal schema ./data/                          # files, folders, globs
ducklocal schema --database warehouse.duckdb      # tables and views
ducklocal schema warehouse.duckdb --table orders  # one relation, whole
ducklocal schema ./logs/ --full                   # everything, however large
```

PATHs expand the way a drop on the window does: a folder's data files recursively (hidden ones skipped, at most 256), a glob's matches, and a database file as if given with `--database`. One JSON object comes back:

```json
{"detail":"full","relations":[{"name":"data/orders.csv","kind":"file","from":"'data/orders.csv'","rows":null,"column_count":2,"path":"data/orders.csv","columns":[{"name":"channel","type":"VARCHAR"},{"name":"amount","type":"BIGINT"}]}],"without_columns":0}
```

| Field | Meaning |
| --- | --- |
| `name` | A file's path as given (relative to the working directory when under it), or a table's name as SQL on this connection reaches it: bare in the default schema, `schema.table` elsewhere |
| `kind` | `file`, `glob`, `table`, `view` or `workbook` |
| `from` | The SQL that reads it, ready to follow `FROM`: a quoted path, or a quoted identifier where one is needed |
| `rows` | The catalog's estimate for a table, exact for Parquet (from its footer), `null` where knowing would take a scan — CSV, JSON, views |
| `column_count`, `columns` | The columns as DuckDB describes them |
| `files` | For a `glob`: how many files it matches |

A glob whose matches are all one format is one relation, read the way a `.dash` `source` reads it (`ducklocal schema 'logs/*.csv'`); `--stats` profiles it as one. A glob over mixed formats, and a folder, list their files one by one.

A small catalog comes back whole (`"detail": "full"`). Past 12 relations or 400 columns it is a **summary** (`"detail": "summary"`) instead. The 5 relations with the most rows keep their columns; the rest list name, kind, rows and column count, and `without_columns` counts them. To get more, `hint` names the two ways: `--table NAME` for one relation (matched by its name, its bare table name, or a file's name) and `--full` for all. A workbook is listed with its `sheets` and no columns, since it has no table function; `--stats` reads its first sheet. A PATH that does not exist, or a `--table` that names nothing, is exit 2.

## Explore and convert files

After confirming the actual file exists:

```bash
ducklocal query --sql "DESCRIBE SELECT * FROM 'sales.csv'"
ducklocal query --sql "SELECT * FROM 'sales.csv'" --limit 20
ducklocal query --sql "SELECT sum(amount) AS total FROM 'sales.csv'"
# Only after authorizing this output path:
ducklocal query --sql "COPY (SELECT * FROM 'sales.csv') TO 'sales.parquet' (FORMAT PARQUET)"
ducklocal query --sql "SELECT count(*), sum(amount) FROM 'sales.parquet'"
ducklocal query --sql "DESCRIBE SELECT * FROM 'events.json'"
```

Do not guess `amount` exists: run `ducklocal schema` or `DESCRIBE` first. Compute full aggregates in SQL rather than summing a truncated preview. CLI calls do not share the GUI's registered views or each other's in-memory tables. Persist explicitly when needed:

```bash
# These are separate statements and separate invocations, with explicit write permission.
ducklocal query --database warehouse.duckdb --read-write --sql "CREATE TABLE sales AS SELECT * FROM 'sales.csv'"
ducklocal query --database warehouse.duckdb --sql "SELECT count(*) FROM sales"
```

A database lock conflict is an error. Close the other writer (including the GUI), or use an authorized copy; never delete lock files or silently create a different database.

## JSONL and nested JSON

`read_ndjson_objects` is the reader for newline-delimited JSON (one object per line, `.jsonl`/`.ndjson`); `read_json`/`read_json_auto` read JSON documents (an array or a single object). Name the reader explicitly for a JSONL file rather than relying on auto-detection. `json_extract_string(col, '$.a.b[0].c')` walks a nested path — object keys and array indexes — and answers VARCHAR:

```bash
ducklocal query --sql "SELECT json_extract_string(event, '$.payload.items[0].sku') AS sku, count(*) AS n FROM read_ndjson_objects('events.jsonl') GROUP BY 1 ORDER BY n DESC"
```

Use `json_extract` instead when the leaf is not a string and you want it as JSON.

## Column statistics

`ducklocal schema --stats` answers the questions that decide a chart, a scale and a number format, before anything is built on them. `DESCRIBE` says a column is a `DOUBLE`; its statistics say it uses two decimals, that its largest value is a thousand times its median, and that its dates skip eleven days in the middle.

```bash
ducklocal schema sales.csv --stats
ducklocal schema ./exports/ --table orders.parquet --stats
ducklocal schema --database warehouse.duckdb --table "my orders" --stats
```

`--stats` profiles one relation: the only one the arguments name, or the one `--table` picks — two or more without `--table` is exit 2, with a hint saying so. It cannot be combined with `--full`. `ducklocal profile TARGET [--database PATH]`, from earlier releases, is the same report and still works.

The relation is a data file — `csv`, `tsv`, `txt`, `parquet`, `json`, `ndjson`, `jsonl` — or a workbook (`xlsx`, `xls`, `xlsb`, `ods`; its first sheet is imported as a TEMP table and profiled), or, with `--database`, a table or view name. A dotted name is read as `schema.table` and each part is quoted, so a name with a space or a capital letter works as written. A relation that names nothing is an argument error (exit 2), not a SQL one.

One JSON object comes back: `target`, `relation` (the SQL the statistics ran against), `row_count`, `elapsed_ms`, and `columns` in the relation's own order. Per column:

| Field | Meaning |
| --- | --- |
| `name`, `type` | as `DESCRIBE` reports them |
| `nulls` | rows where the column is `NULL` |
| `distinct` | exact, not estimated |
| `unique` | present and `true` when `distinct` equals `row_count` |
| `min`, `max` | as text, so a `DECIMAL` keeps its digits |
| `decimals` | digits after the point the column **actually uses**, which is not its declared scale |
| `median` | a value the column holds, not an interpolation between two of them |
| `max_over_median` | how many times the largest value is bigger than the median — the number that decides a linear axis from a logarithmic one |
| `covered_days`, `span_days`, `missing_days` | days the column names, days between its ends, and the difference: `0` is a continuous series, anything else is holes a line would draw over |

`decimals`, `median` and `max_over_median` appear on numeric columns; the day fields on dates and timestamps. `LIST`, `STRUCT`, `MAP` and `UNION` columns report `nulls` only — `min` and `max` are not defined on them.

A text column that holds numbers and one short word, such as R's `NA`, also reports `null_marker` and `null_marker_rows`. The report then adds a `hint` naming the `read_csv` call that reads the column as numbers.

Statistics are exact and read the whole relation, so they cost a full scan. `--limit` does not apply: a profile of a sample is not a profile.

## Show work in the running window {#show-work-in-the-running-window}

`ducklocal open` hands something to the DuckLocal window that is already running — starting one if none is — and brings it forward. It is how an agent puts its work in front of a person, where they can carry on from it:

```bash
ducklocal open --title "Revenue by channel" --run --sql "SELECT channel, sum(amount) AS total FROM 'orders.csv' GROUP BY 1"
ducklocal open sales.dash                    # a dashboard tab
ducklocal open ./data/ --sql-file analysis.sql
echo "SELECT 42" | ducklocal open --sql-file - --no-launch
```

- PATHs open the way a drop on the window opens them: a `.dash` file as a tab, a DuckDB database file as the window's database, data files, folders and globs attached as views. Relative PATHs resolve against the command's directory. A request that arrives while the window is still starting waits until its startup open has finished.
- `--sql` or `--sql-file` (`-` reads stdin) opens a new query tab, titled `--title`. `--run` runs it once the PATHs have attached.
- `--no-launch` fails with kind `not_running` instead of starting a window — for a machine where a window appearing unasked would be unwelcome.

Success prints `{"delivered": true, "launched": BOOL}`: delivery, not a result. What opens runs in the window's own session — its connection, its attached views, its history — and any error shows there, not on stdout. Exit 2 for arguments; 1 with kind `not_running` (no window reachable), `refused` (the window rejected the request) or `launch`.

The window listens on a loopback port. It writes the port and a random token to a file only your account can read (see [Settings and app data](settings-and-data.md#where-things-are-stored)); another user on the machine, or a web page aiming at localhost, cannot reach it.

### Read what the window shows

`ducklocal open --state` goes the other way: it opens nothing, never starts a window, and prints what the running window shows — so an agent can pick up where a person left off, instead of asking them to paste their query.

```bash
ducklocal open --state
ducklocal open --state sales.dash
ducklocal open --state --active
```

```json
{"database":":memory:","attached":[{"path":"/Users/me/data/orders.csv","name":"orders","rows":224}],"active_tab":1,"tabs":[{"kind":"query","title":"Query 1","active":false,"sql":"SELECT …","result":{"status":"rows","columns":[…],"row_count":5,"truncated":false,"preview":[["Web","132771.24"],…],"sql":"SELECT …","elapsed_ms":4}},{"kind":"dashboard","title":"sales","active":true,"path":"/Users/me/data/sales.dash","unsaved":false,"dashboard":{"running":false,"error":null,"stale":null,"plots":[…],"filters":[{"name":"channel","plot":"revenue_by_channel","column":"channel","picked":true,"value":"Web"}]}}]}
```

- `database` is the open database file, or `":memory:"`; `attached` lists the files the window registered as views, with the view `name`. A CLI query cannot read those views — use the `path`.
- A **query** tab has `sql`, the editor's text whether or not it ran, and `result`: `status` is `empty`, `running`, `rows`, `affected`, `failed`, `explain` or `profile`. For rows: `columns`, `row_count`, `truncated`, `preview` (the first 20 rows as the grid shows them, as strings), `elapsed_ms` and the `sql` that produced them; for `affected`, `count` and `elapsed_ms`; for a failure, `error`.
- Once the person has picked columns in the Chart tab, a result also has `chart`: `type`, `x`, `y`, `split`, and `dash`, a `query` and a `plot` block that draw the same chart in a dashboard.
- `explain` is what **Explain** shows: `plan`, DuckDB's `EXPLAIN` output as an array of text lines, and `elapsed_ms`.
- `profile` is what **Profile** shows (see [SQL editor](sql-editor.md)): `plan`, the operator tree `EXPLAIN ANALYZE` measured, as an array of roots, each `{operator, seconds, rows, estimated, children}` — `seconds` is the operator's own time with its children excluded, `rows` what it produced, `estimated` the optimizer's guess at `rows` (`null` where the plan states none), `children` the same shape again. `operator_seconds` is the sum of every operator's own time, which is what the shares add up to, not the wall clock; `elapsed_ms` is the whole run.
- A **dashboard** tab has `path`, `unsaved`, and `dashboard`: its `comments_file` path, `plots` (name, type, title, query, rows, `open_comments`, and `failure` when one cannot draw) and its `filters` with what each has picked. `value: null` with `picked: true` means the NULL value was picked.

Use `--state sales.dash` to keep only tabs showing that dashboard, or `--state --active` for only the active tab. A path that is not open returns exit 1, kind `not_open`, with the open dashboards in the hint. `active_tab` is the active tab's index within the returned `tabs`, or `null` if it is not among them; `database` and `attached` remain included.

`--state` accepts either one `.dash` PATH or `--active`, not both. `--no-launch` is accepted and changes nothing. Other options, including `--sql` and `--run`, are invalid (exit 2). Exit 1 with kind `not_running` when no window is up, and `timeout` when it does not answer within 5 seconds.

## Check a dashboard spec

The format itself — blocks, attributes, plot types, SQL rules and common messages — is described in [Dashboards (.dash)](dashboards.md); this section covers the command.

A `.dash` file declares a dashboard the way Terraform declares infrastructure — queries and plots as blocks, references between them — rather than scripting one. It is easy for a person or an agent to write and diff.

```hcl
query "latency" {
  sql = <<SQL
    SELECT timestamp, service, avg(latency) AS latency
    FROM logs
    GROUP BY timestamp, service
  SQL
}

plot "latency" {
  type   = "line"
  query  = query.latency
  x      = timestamp
  y      = latency
  series = service
}
```

```bash
ducklocal check dashboard.dash
ducklocal check dashboard.dash --database warehouse.duckdb
ducklocal check dashboard.dash --summary
```

A `source` block names data files, relative to the `.dash` file, for the queries to read. A `query` block holds one `sql` attribute — one read-only statement, as a heredoc or a string. `SELECT`, `WITH`, `FROM`-first, `VALUES`, `SHOW`, `DESCRIBE`, `SUMMARIZE` and `PIVOT` are accepted; anything that could write — DDL, DML, `COPY`, `ATTACH`, `INSTALL` — is a diagnostic, and the GUI refuses to run it, because a dashboard's queries run whenever the file is opened. A `plot` block holds `type` (one of `line`, `bar`, `stacked_bar`, `area`, `scatter`, `pie`, `heatmap`, `histogram`, `map`, `table`, `card`, `text`), `query` (a reference like `query.latency` to a query block in the same file), its columns, and optional `title` and `width`. A `filter` block makes a bar chart or a table clickable, and a query reads the pick as `$name`. `#` and `//` comment to end of line. [Dashboards (.dash)](dashboards.md) has every attribute.

A `text` plot is the exception: it holds `title`, `text`, or both and takes no `query`. Cards can also use `note` and `tone`; see [Cards and text panels](dashboards.md#cards-and-text-panels).

First the static checks: each query's SQL through the real DuckDB parser on a throwaway connection, the way `query` validates before running, and every `$name` against the filter blocks — the SQL is parsed, and run, with each `$name` as `TRUE`, which is what the dashboard runs before anything is picked.

Then every query runs, exactly as opening the dashboard would run it — read-only, with its sources, through the same code path and row budget — and each plot's columns are checked against the columns the query actually returns: a `y` that is not numeric is an error for every chart type, so are a map's non-numeric coordinates or `size`, and so is a filter `column` its plot's query does not return.

- **Without `--database`** the queries run in memory, which is all a dashboard over its own `source` files needs. A query that reads a table no source defines may be meant for a database the check was not given: it is listed under `unresolved` (query, line, reason) rather than failed, and its plots' columns go unchecked.
- **With `--database PATH`** (existing file, read-only) they run on that database. Pass it whenever the queries read a database's tables: anything under `unresolved` went unchecked.

Every query that reads a filter is also bound with a picked value, without running that variant. This catches a filter column missing from a consuming query, even when the initial `$name = TRUE` version works. A filter must reference a column returned by its picking plot too.

Running rather than only planning matters: some failures, such as a cast the build cannot perform or a value that will not convert, appear only once rows are read, and a check that passes them would hand over a dashboard that cannot draw. Queries run in full, so a check against a large database takes as long as opening the dashboard does.

Success is one JSON object listing the file's sources (with each `resolved` path), queries (with their result columns and `rows`, how many each returned with nothing picked, plus `truncated: true` when the dashboard's row budget cut it off), filters (with the `queries` that read each), plots, `unresolved`, and `empty` — the queries that ran and returned no rows. An empty query passes every other check, but it is usually a regex, filter or join that matched nothing, so look at each one. With `--summary`, success is one compact JSON line:

```json
{"file":"sales.dash","ok":true,"sources":1,"queries":3,"filters":1,"plots":6,"unresolved":[],"empty":[]}
```

The four block fields are counts; `unresolved` lists the queries that could not be checked, and `empty` those that returned no rows.

A faulty block is left out while valid blocks continue through validation. Spec errors and query failures are gathered in the same run, one `file:line: message` each. If any spec diagnostic exists, exit is 2 with kind `spec`; if only SQL execution or binding fails, exit is 1 with kind `sql`. Database and I/O failures also exit 1. `--summary` does not hide diagnostics.

The same file opens as a dashboard tab in the GUI — `ducklocal dashboard.dash`, `ducklocal open dashboard.dash`, or drag it onto the window — with plots on a 12-column grid and a plot that cannot draw showing the reason inline. See [Dashboards (.dash)](dashboards.md).

## Review comments on a dashboard {#review-comments-on-a-dashboard}

People review a dashboard by commenting on its plots in the window — hover a plot, click the speech bubble (see [Review with comments](dashboards.md#review-with-comments)). `ducklocal comments` is the agent's side: it reads the threads and answers them, without a window.

```bash
ducklocal comments sales.dash [--all]
ducklocal comments sales.dash --reply c1 --text "Sorted by revenue, descending" [--resolve]
ducklocal comments sales.dash --resolve c1
ducklocal comments sales.dash --reopen c1
ducklocal comments sales.dash --add revenue_by_channel --text "Is this net of refunds?"
```

- With no action, prints the open threads: `{file, comments_file, open, threads}`. `--all` includes resolved ones.
- Each thread has `id`, `plot`, and what the agent needs to find it in the spec: `plot_title`, `plot_query`, and `plot_lines`, the first and last line of the `plot` block (`null` when the spec does not parse). `plot_missing` is `true` when no plot of that name is left. Then `resolved`, and `comments`, each with `author` (`user` or `agent`), on an agent's comment its `name` (the name [`presence`](#agent-presence) shows), `text` and `at` (RFC 3339, local time).
- `--reply ID --text TEXT` adds the agent's answer. A reply to a resolved thread reopens it, unless `--resolve` is given too.
- `--resolve ID` and `--reopen ID` change a thread's state; `--add PLOT --text TEXT` starts a thread, signed as the agent.
- A thread's number works as its ID: `--reply 1` means `c1`. `--add` refuses a plot the spec doesn't have (exit 2), and the hint lists its plots.
- An action prints `{thread}`, as changed.

The threads are kept beside the spec, in `FILE.comments.json`. A running window notices a change within a second and shows it on the plot. An unknown thread id is exit 2, with a hint to list the threads; a spec file that does not exist is exit 1.

A review, start to finish: read the open threads, edit the spec, run `ducklocal check`, then answer each thread with what changed and `--resolve` it. Use `--reply` without `--resolve` to ask the person something back.

## Show agent activity {#agent-presence}

`check` and `comments` automatically tell a running window which dashboard the agent is working on. `presence` can announce an edit before it happens:

```bash
ducklocal presence sales.dash --doing editing --plot revenue_by_channel,daily_revenue
ducklocal presence sales.dash --doing reading --agent "Analyst"
ducklocal presence sales.dash --doing idle
```

- `--doing` accepts `reading`, `editing`, or `idle` (`done` is an alias for `idle`).
- `--plot` takes comma-separated plot names. With `editing`, new names are allowed and appear as placeholders. With `reading` or `idle`, an unknown name is exit 2.
- The displayed name comes from `--agent`, then `DUCKLOCAL_AGENT`, then the detected agent environment (Claude, Codex, Cursor or Gemini), falling back to `Agent`.
- Output is `{delivered}`. No running window produces `{"delivered":false}` without an error or launching a window.

The name stays in the dashboard's corner while commands keep the session active. Click it for the activity log. Thirty seconds of silence or `--doing idle` ends the session; changed and added plots remain marked until viewed, and removed-plot notices until dismissed. See [See an agent at work](dashboards.md#agent-presence).

## Edit a dashboard spec with LSP

`ducklocal lsp` is a Language Server Protocol server for `.dash` files, over stdio, for editors to spawn. It gives any LSP-capable editor what the GUI's own spec editor has:

- **Diagnostics**: the ones `check` reports, published on every open and change (full-document sync, all of them severity Error — they are mistakes, not style).
- **Completion**: block types, attribute names, plot types and the names after `query.` and `plot.`, each with a text edit that replaces the word being typed.
- **Hover**: documentation for blocks, attributes and references.
- **Go to definition**: from a `query.name` or `plot.name` reference to its block's name.

With `--database PATH` each query's SQL is additionally validated by the real DuckDB parser, the way `check --database` does.

Point your editor's generic LSP support at `ducklocal lsp` for `*.dash` files — the command takes no document arguments; the editor speaks to it on stdio. In Neovim (0.10+):

```lua
vim.api.nvim_create_autocmd("FileType", {
  pattern = "dash",
  callback = function(event)
    vim.lsp.start({
      name = "ducklocal",
      cmd = { "ducklocal", "lsp" },
      root_dir = vim.fs.dirname(vim.api.nvim_buf_get_name(event.buf)),
    })
  end,
})
```

In VS Code, any generic-LSP extension does the same with command `ducklocal`, arguments `["lsp"]`, document selector `dash`. Exit codes: **0** after a clean `shutdown`/`exit`, **1** when the client goes away without one, **2** for a bad command line or a failed protocol handshake.

## JSON contract

Successful queries write exactly one JSON object to stdout, followed by a newline:

```json
{"columns":[{"name":"n","type":"Int32"}],"rows":[[1]],"row_count":1,"truncated":false,"total_rows":1,"total_rows_exact":true,"elapsed_ms":1}
```

`columns` and each row preserve column order and duplicate column names. `type` is the **Arrow debug representation**, not the original DuckDB SQL type name (for example HUGEINT and DECIMAL can share an Arrow carrier). `row_count` counts returned rows. `total_rows` counts the rows produced, including those counted after the output cap; `total_rows_exact: false` means counting stopped after up to 100,000 additional rows, so that total is a lower bound. Neither count is an affected-row count. `elapsed_ms` measures preparation, execution, and row encoding, not file loading/connection setup. Zero-row results retain metadata. DDL/DML expose the actual DuckDB result, including `Count` columns: INSERT/COPY commonly return `[[N]]`; CREATE TABLE commonly returns no rows. They are not classified from the first SQL keyword.

Cell encodings:

| SQL value | JSON encoding |
| --- | --- |
| NULL | `null` (never the string `"NULL"`) |
| Boolean, finite float | JSON boolean/number |
| Integer within ±9,007,199,254,740,991 | JSON number |
| Larger integer, including HUGEINT/UHUGEINT | `{"encoding":"integer","value":"9007199254740992"}` |
| Decimal | `{"encoding":"decimal","value":"123.450"}` with exact decimal text |
| Text, enum | JSON string |
| Blob, geometry | `{"encoding":"hex","value":"00ff"}`; lowercase bytes, geometry uses WKB |
| Date | `{"encoding":"date","unit":"Day","value":"1","iso":"1970-01-02"}`; days since 1970-01-01 |
| Timestamp | `{"encoding":"timestamp","unit":"Nanosecond","value":"123456789","iso":"1970-01-01T00:00:00.123456789"}`; signed count since Unix epoch; unit may also be Second/Millisecond/Microsecond |
| Time | `{"encoding":"time","unit":"Microsecond","value":"123456","iso":"00:00:00.123456"}`; count since midnight |
| Interval | `{"encoding":"interval","months":1,"days":2,"nanos":"3000"}` |
| NaN, positive/negative infinity | `{"encoding":"float","value":"NaN"}` / `"inf"` / `"-inf"` |
| List, array | JSON array, recursively encoded (including nested nulls) |
| Struct | `{"encoding":"struct","fields":[["name",VALUE],...]}` |
| Map | `{"encoding":"map","entries":[[KEY,VALUE],...]}`; not a JSON object, so keys retain their types |
| Union | `{"encoding":"union-value","value":VALUE}`; active value only, member tag is not retained |

Temporal `value`s are exact counts, so no fractional precision is lost; date/timestamp infinity sentinels remain raw counts. Beside each, `iso` is the same value as ISO 8601 text for reading, with as many fraction digits as the unit holds. A timestamp from a column with a time zone (Arrow metadata) is a UTC instant, and its `iso` ends in `Z`. `--format md` renders dates and timestamps readably too. Because the pinned DuckDB Arrow wrapper cannot distinguish nested HUGEINT, UHUGEINT, and DECIMAL(38,0), non-null occurrences produce a clear error instead of silently changing sign or precision. Explicitly cast such fields to VARCHAR in SQL. Unknown unsupported types also produce an error; do not mistake an error for an empty result. Union encoding is an active-value representation, not a lossless union round trip.

Errors write one JSON object to **stderr**, leaving stdout empty:

```json
{"error":{"kind":"argument","message":"Unknown query option: --limt","hint":"Did you mean --limit?"}}
```

`hint` is there when the next step is known, in this CLI's own terms: the flag a misspelled one was meant to be, `--read-write` for a write refused on a read-only database, `ducklocal schema` for a table that does not exist, `TRY_CAST` for a value that will not convert, splitting a script that has more than one statement, or `AS` for a keyword used as a bare column alias (`SELECT count(*) AS minutes`, or quote it). An agent should act on it before rewriting the SQL. It is absent when there is nothing to add to DuckDB's own message, such as a syntax error.

Exit codes: **0** success, **2** invalid arguments/statement count, **1** SQL, database, I/O, or output failure. Error kinds are `argument`, `sql`, `database`, `io`, `output`, `spec` for `check`, and `not_running`, `not_open`, `refused`, `timeout` or `launch` for `open`. Check exit status before parsing stdout and inspect `truncated` before reporting completeness. Help/version are plain text exceptions. Transport failures such as a closed stdout pipe cannot guarantee an empty/complete output stream.

## Install the official skill

The skill teaches an agent the habits that keep its answers honest: inspect the schema, preview a bounded sample, compute aggregates in SQL, check `truncated`, and answer from returned values. It covers schema inspection, column statistics, conversions, database queries, `.dash` dashboards and their review comments, and handing work to the window and reading it back; it does not provide S3 browsing, spatial-specific commands, session memory, or an MCP server.

**From the app.** Open **Command line & AI…** (the robot button in the title bar, or the DuckLocal menu):

- **Install command** puts `ducklocal` on your `PATH`, so an agent's shell can run it.
- **Add to Claude Code** writes the skill to `~/.claude/skills/ducklocal`, where Claude Code finds it in every project. Only the skill's own two files are written; anything else in that folder is kept. When a DuckLocal update ships a newer skill, the dialog offers **Update skill**.
- **Copy brief** copies a short explanation for any other agent — Codex, Cursor, a chat window — naming the command and how to use it.

**By hand.** The skill is [`skills/ducklocal`](https://github.com/JetSquirrel/ducklocal/tree/main/skills/ducklocal). To add it to a single project instead (example for Claude Code):

```bash
# Run from the DuckLocal checkout; replace the destination with your project.
mkdir -p /path/to/your-project/.claude/skills
cp -R skills/ducklocal /path/to/your-project/.claude/skills/
```

Inspect an existing destination first rather than overwriting local changes. Other agents can use the same `SKILL.md` plus `references/cli.md` in their supported skills directory.
