Skip to main content
refinery is TextQL’s command-line tool. It queries a connector into a remote Python session and runs Python or shell in that session, so an analysis can be driven from a terminal — or by a coding agent — instead of the browser.

Let your coding agent set it up

The fastest path: paste this one line into Claude Code (or any coding agent with a terminal and web access), then approve one browser prompt when asked.
Or hand it over in one click — open in Claude Code or open in Cursor. Both fill the prompt into the agent and stop there; you read it and press Enter. Codex has no prompt link of its own, so paste the line above into it instead.
Claude Code registers its claude-cli:// handler the first time you send a prompt in an interactive session on that machine. If the link does nothing, run claude, send any prompt, then click it again.
Everything below is the same setup, spelled out for a human.

Install

TextQL cloud:
Every other deployment — VPC, on-prem, staging — serves its own copy of the CLI, and the download requires you to be signed in. Get it from the browser: Settings → Desktop & CLI detects your operating system, offers the matching build, and shows the command to unpack it. On a white-labeled deployment the same page is named Settings → CLI. A piped curl | sh cannot carry your session, so running one against your own deployment returns 401. That is expected, and not something an administrator can switch off. The installer detects your platform, verifies the download against a checksum, and installs to /usr/local/bin, falling back to ~/.local/bin when that is not writable. Set TEXTQL_INSTALL_DIR to choose somewhere else. It also installs a man page where it can, so man refinery works, and teaches your coding agents about refinery (the same thing refinery init --global does — see below). Builds are provided for macOS (Apple Silicon and Intel) and Linux (x86_64 and arm64). There is no Windows build.

Upgrading from ana

This tool was called ana before. An existing ana cannot update itself into refinery — reinstall once, using the command above. Until you do, it keeps working on the version it has and reports an update failure on every run. Each deployment also serves the build it ships, so if yours is still on an older release it installs ana and this page is ahead of it. Read ana for refinery throughout until that deployment is upgraded, then reinstall. The reinstall replaces ana with refinery, removes the old binary and its man page from that location, and switches your coding agents to the new skill. If you had installed ana somewhere else on your PATH, delete that copy by hand. You do not need to sign in again. The first refinery command moves ~/.config/ana to ~/.config/refinery, credentials and sandbox names included, and ANA_CONFIG_DIR, ANA_NO_AUTO_UPDATE, and ANA_AGENT are all still honoured if you set them yourself.

Sign in

This opens the approval page in your browser, printing the URL and code to paste when no browser can be opened (an SSH session, say) — there is no API key to copy around. Login asks for the CLI’s standard scope set (api:read api:write mcp:tools) up front, so commands do not fail later on a scope the grant never had; the approval page shows exactly what is requested, and your organization’s roles still govern what each call is allowed to do. Pass --scopes "<scope> ..." to request a narrower grant. refinery auth status shows who you are and what the grant allows, and refinery auth upgrade widens an older or narrowed grant (it too requests the standard set by default). Raw SQL is the one deliberate exception: ExecuteQuery needs connector:raw_sql, which no default request includes — name it explicitly, e.g. refinery auth upgrade --scopes "connector:write connector:raw_sql". Then confirm what the deployment allows:
One call reports your identity, your permissions, which execution tools the organization has enabled, and which sandboxes you have running.

First analysis

The query result lands as a dataframe named orders inside a remote Python session, and the next refinery exec python call sees it. Sandboxes are named (--sandbox revenue-q3), each has its own kernel, and the state in one carries across calls until it is idle long enough to be reclaimed. man refinery documents every command, the exit codes, and the environment variables.

The context Ana already has

When your organization uses the Ontology, every chat in the web app is given a resolved set of context files before Ana sees the question: the root ANA.md, an ANA.md from every subdirectory, and whatever ana-config.yaml marks for the connectors, API keys, and roles in play. The CLI reads the same set:
A chat is scoped to the connectors attached to it; a CLI session is not — any connector you can see is one command away. So the default resolves against all of them and returns the union, and --connector narrows it to what a task actually uses, which is what a chat on those connectors would have got. Each file comes back with its full text, its source (root_ana_md, subdir_ana_md, or auto_attach), and whether it is certified golden. --paths-only lists the set and its sizes without the bodies. An agent should run this first and read every file it returns — those files are the organization’s standing instructions about its own data (metric definitions, which table is authoritative, when the fiscal year starts), and the web app applies them on every turn. count: 0 simply means nothing in the Ontology applies to that scope.

Shell in the sandbox

Every refinery exec bash call is a fresh process, so nothing carries between calls but the filesystem — the opposite of refinery exec python, whose kernel keeps variables and dataframes until the sandbox restarts. Both reach the same sandbox, so bash sees the files Python wrote and the other way round. --env K=V, repeatable, sets environment variables for the one call. A command is killed after 180 seconds. The sandbox cuts each stream off at 1 MB. refinery then truncates again at 400,000 characters before printing, since this output usually lands straight in a model’s context. --max-output N adjusts that cap and --max-output 0 removes it; negative values are refused. The JSON on stdout carries stdout, stderr, truncated, and exit_code. That exit status is your command’s, not refinery’s own, so a command that fails still leaves refinery exiting 0. Branch on that field, not on the process exit code. Bash is enabled per organization: refinery info reports whether it is available, and refinery exec bash exits 4 when it is not.

The API underneath

refinery is a client of a public API, not a private one: exec bash, exec python, and connector db query are the Exec, ExecuteCode, and LoadConnectorData methods of the Sandbox Exec Service, and any API client can call them directly. See the Sandbox Exec Service reference for request and response fields. Every method that runs something needs the sandbox write permission; reading tool availability needs sandbox read. Creating a sandbox additionally requires a credential attributed to a member, because sandboxes are owned per member — a service account without a member identity can’t reach what it just created. The API reaches only the sandboxes it created itself. The sandbox behind a chat, a dashboard, or a data app is not addressable through it, and neither is another member’s — both answer “not found” rather than distinguishing the two, so an id alone reveals nothing. Holding the sandbox write_private permission widens this to every API-created sandbox in the organization.

Versions

The CLI version available to you is the one your deployment ships, and it upgrades itself from that deployment — never from anywhere else. A new release reaches your users when your TextQL server is upgraded, not before. That is deliberate: the CLI a user runs always speaks the same API as the server it is talking to, so a long-lived deployment never has to cope with a newer client. It holds for cloud users who installed with the one-liner too — the install URL is not consulted again afterwards. refinery update --check reports what your deployment ships without changing anything; refinery update applies it. An update also brings your machine’s man page and the skill at ~/.claude/skills/refinery/SKILL.md up to the new version, so your coding agents are told about the CLI you now have rather than the one you installed. It only ever refreshes files you already have — an update never creates a man page or a skill you had not installed, so if that file is missing, run refinery init --global. A skill file you have edited yourself is never overwritten either: the update says so and leaves it, and refinery install skill --claude --force replaces it when you want that. A skill committed in a repository (refinery init without --global) belongs to that repository — an update names it if it is stale but never rewrites it; re-run refinery init --force there and commit the result.

Teaching an agent to use it

run at a repository’s root, teaches every coding agent that works in that repo to use refinery: it writes the skill file (how to sign in, load data into a sandbox, analyze it there, and stay out of other sessions’ way) to .claude/skills/refinery/SKILL.md, and points agents at it from AGENTS.md — the cross-agent instructions file — and from CLAUDE.md when that file doesn’t already defer to AGENTS.md. Commit the results and the whole team’s agents inherit them. Re-running is safe: it reports what changed and never duplicates its stanzas, and it refuses to overwrite a hand-edited skill file without --force. refinery init --global targets you instead of a repository: the skill goes to ~/.claude/skills/refinery/SKILL.md and the pointer stanza to ~/.claude/CLAUDE.md, so every session on the machine knows refinery in any directory. The install script runs this automatically. In both modes the stanza tells an agent how to install refinery if the binary is missing — so a teammate who clones a repo you ran refinery init in gets an agent that can set itself up.

Instructions for coding agents

You are a coding agent whose user asked you to set up the refinery CLI. Do the following, in order:
  1. Check whether refinery is already installed: run refinery update --check. If the command exists, skip to step 3.
  2. Install it. Ask the user whether they use TextQL cloud (app.textql.com) or their own deployment. Cloud: curl -fsSL https://cli.textql.com/cli/install.sh | sh. Own deployment: downloads are authenticated, so ask the user to fetch the archive from Settings → Desktop & CLI in the app and give you the file path; then unpack it and put refinery on PATH (the same page shows the exact command).
  3. Sign in: run refinery auth login (add --url <deployment url> off cloud). It prints a verification URL and a user code on stderr — relay both to the user and wait while they approve in a browser. No API key is involved.
  4. Verify: run refinery info and tell the user who they are signed in as and which execution tools their organization has enabled.
  5. If you are working inside a repository, run refinery init at its root — it writes the agent skill and points AGENTS.md/CLAUDE.md at it — and suggest committing the files it reports as changed. Otherwise run refinery init --global for this machine (the install script already did this if you installed via install.sh).
  6. Show it working end to end: refinery connector db list, pick a connector, refinery connector db query <id> --sql '...' --as df on one small table, then refinery exec python 'print(df.describe())' — and report the result. Every command prints one JSON document on stdout; branch on its fields, not on exit codes alone.

Who can download

The download routes require authentication — a browser session or a bearer token — on every deployment, including TextQL’s cloud. Your deployment never serves its artifacts to an unauthenticated caller, so a deployment behind a VPN or in an air-gapped VPC stays that way. The public one-liner is not an exception: it points at a separate static host carrying only the builds TextQL’s cloud is running, not at any deployment. Downloads are rate limited per client address and support resumable range requests, so an interrupted download continues rather than starting over.

Reporting a problem

sends a bug report to the team that maintains TextQL, from the terminal where the problem happened. Run it without a description and it asks for one. A terminal report is hard to act on without knowing what ran, so the command attaches the sandbox your session was working in, along with the CLI version and the platform. What it sends is the sandbox’s identifier and a link to its activity, never the code, queries, or results it handled: whoever picks the report up opens that activity themselves, under their own access. By default the attached sandbox is the one named default on this machine; --sandbox names a different one, by local name or id, and --no-sandbox reports without one. You can only attach a sandbox of your own — naming someone else’s is refused. Describe the problem in your own words and leave data out of the description: the sandbox link is how the team sees what happened.

Troubleshooting

The Settings page has no CLI section. It hides itself inside the desktop app, where a shell is already at hand. It is not hidden for white-labeled deployments — look for CLI rather than Desktop & CLI. curl | sh returns 401. Expected against your own deployment: downloads are authenticated everywhere. Cloud users install from https://cli.textql.com/cli/install.sh; everyone else uses Settings → Desktop & CLI. The download 404s. The deployment predates CLI support — upgrade it. A command exits 3. Two different denials share this exit code, and the error message tells them apart:
  • insufficient_scope — the CLI’s OAuth grant is missing a scope. This one is self-service: run refinery auth upgrade and approve the widened grant (by default the upgrade requests the standard scope set, so one approval covers everything except raw SQL — connector:raw_sql must be named explicitly with --scopes; a grant narrowed with --scopes stays narrow only if you narrow the upgrade too).
  • insufficient permissions: your role lacks <permission> — your org role is missing that permission, and refinery auth upgrade will not fix it. An org admin has to grant it: Settings → Roles → your role → ⋮ → Manage Permissions, check the permission, save. It takes effect immediately — no re-login needed. The common case is the sandbox permission (refinery info, refinery exec, and refinery connector db query all run through sandboxes), which only the admin role holds by default.
Run refinery doctor to check for the second case before it bites: it diffs the grant’s scopes against your role’s permissions and prints a copy-pasteable request for your org admin describing exactly what to grant. A command exits 4. Your organization has that execution tool disabled. Run refinery info to see which are available, and ask an administrator to enable it. Something else is wrong. Send it to us with refinery feedback — see Reporting a problem.