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.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.Install
TextQL cloud: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
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:
First analysis
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 rootANA.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:
--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
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
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 therefinery CLI. Do the
following, in order:
- Check whether
refineryis already installed: runrefinery update --check. If the command exists, skip to step 3. - 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 putrefineryon PATH (the same page shows the exact command). - 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. - Verify: run
refinery infoand tell the user who they are signed in as and which execution tools their organization has enabled. - If you are working inside a repository, run
refinery initat its root — it writes the agent skill and pointsAGENTS.md/CLAUDE.mdat it — and suggest committing the files it reports as changed. Otherwise runrefinery init --globalfor this machine (the install script already did this if you installed viainstall.sh). - Show it working end to end:
refinery connector db list, pick a connector,refinery connector db query <id> --sql '...' --as dfon one small table, thenrefinery 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
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: runrefinery auth upgradeand approve the widened grant (by default the upgrade requests the standard scope set, so one approval covers everything except raw SQL —connector:raw_sqlmust be named explicitly with--scopes; a grant narrowed with--scopesstays narrow only if you narrow the upgrade too).insufficient permissions: your role lacks <permission>— your org role is missing that permission, andrefinery auth upgradewill 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, andrefinery connector db queryall run through sandboxes), which only the admin role holds by default.
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.