Hailer
Hailer lets you chat with your data. Put CSV, Parquet or JSON files in a folder, ask questions in plain English in the terminal, and the agent explores the data with Polars and DuckDB inside a live marimo notebook, putting tables, charts and summaries in the notebook open in your browser. It is built for analysts who want to go from a file to a chart in minutes, whatever the data is about: sales, operations, finance, survey results, logs. Datasets stay on your machine: the model endpoint receives your messages, code, and compact results, never the raw files. The notebook code runs in Hailer's own Python by default, or, if you choose, in a Docker container that sees only your notebooks and data and has no network.
Architecture
Model endpoint
(OpenAI, or your own endpoint:
Responses API or Chat Completions)
▲
│ HTTPS
┌────────┴────────┐
│ Hailer │ one Python process: a LangChain agent
Terminal ─────► │ conversation │ (create_agent + ChatOpenAI), Hailer's system
uvx hailer │ + tools │ prompt, the conversation kept in .hailer/
│ │
│ marimo_execute, marimo_status, notebook_cells, notebook_list,
│ notebook_create, notebook_open, notebook_close,
│ list_periods, load_skill, read_skill_file, fetch_page
└────────┬────────┘
│ HTTP + SSE (/api/sessions, /api/kernel/execute),
│ with the server's token
┌────────▼────────┐
│ Marimo runtime │ scratchpad over the kernel globals +
│ Polars · DuckDB │ marimo._code_mode for durable cells
│ Python │
└────────┬────────┘ local (default): Hailer's own Python, as you
│ docker: a Linux container that sees only the
Browser UI notebooks folder and, read-only, the data folder
The CLI runs the agent in its own process, with the model and provider taken from hailer.toml. The agent
has exactly the eleven tools above and nothing else: no shell, no file editing. The tools talk to the
running marimo server over plain HTTP. Code from the agent runs in marimo's scratchpad, a temporary
namespace that can read every notebook variable, and durable changes (new cells, edits, runs) go through
marimo's code-mode API so they appear immediately in the browser. The notebook file on disk is written by
marimo itself, never edited behind the kernel's back.
Where marimo and its kernel run is the kernel runtime, set by [kernel] runtime: local (the default)
starts marimo in Hailer's own Python, docker starts it in a container (see
Isolated kernel (Docker)). The agent, the conversation and the API key stay in
the Hailer process either way.
Requirements
- Windows 11 is the primary target; macOS and Linux work too. No Git Bash, curl, jq or WSL is needed.
- uv. Hailer runs with
uvx, so no project or virtual environment is needed; uv fetches Python 3.12 or newer if the machine has none. - A web browser. A kernel session only exists while the notebook is open in a browser tab; Hailer tells you the URL to open when it is not.
- An API key for the provider you configure: OpenAI, or your own OpenAI-compatible endpoint.
- Optional: Docker Desktop (Windows, macOS) or Docker Engine (Linux), only if you want notebook code to run in an isolated container (see Isolated kernel (Docker)). The default needs no Docker.
- Pinned dependency:
marimo==0.24.2(Hailer drives its privatemarimo._code_modeAPI, which has no stability guarantee). The agent useslangchain,langchain-openaiandlanggraph-checkpoint-sqlitewithin their current major versions. Upgrade deliberately and re-run the tests. Nothing Hailer installs is a standalone executable; it is all Python packages.
Installation
Nothing to install beyond uv. uvx hailer ... fetches Hailer from PyPI the first time, keeps it in uv's
cache with an environment of its own (marimo, Polars, DuckDB and the rest), and runs it from there. No
pyproject.toml or .venv is needed in your folder, which suits ad-hoc analysis. uvx hailer@latest ...
picks up a newer release; uvx hailer@X.Y.Z ... pins one.
For a permanent hailer command instead, uv tool install hailer (or pip install hailer into an
environment of your own) and drop the uvx from the commands below.
Working on Hailer itself: from a checkout of this repository, uv sync and then uv run hailer ..., so
the code you are changing is what runs. The checkout also has optional sample data (six months of
synthetic sales orders, later months gain a column):
uv run python scripts/make_sample_data.py # writes data/25-01 sales.parquet ... 25-06
uv run python scripts/make_sample_data.py --help # --out, --months, --rows, --start, --seed, --dataset
Quick start
In an empty folder, or the one that holds your data, one terminal is enough:
uvx hailer init # hailer.toml, .config/hailer/{context,skills,prompts}, notebooks/analysis.py, data/
uvx hailer login openai # stores your API key in the OS credential store (or set OPENAI_API_KEY)
uvx hailer notebook # starts marimo, opens the notebook in your browser, and chats right here
hailer init creates the starter notebook ([hailer].notebook) and the data folder ([hailer].data_dir)
that hailer.toml names. It never overwrites anything except hailer.toml itself, and only with
--force, so running it again just fills in what is missing (a deleted notebook, say). Put the files you
want to analyse in data/: CSV, Parquet or JSON, with any names (see Data conventions).
The notebook lists them when it opens, and you can start asking straight away.
hailer notebook runs the startup checks, starts a marimo server on the notebooks folder in the
background, opens the active notebook's URL so the kernel gets a session, then runs the chat in the same
terminal. One server hosts every notebook in the folder, and marimo's own home page (the server URL
without ?file=) lists them all. When you leave the chat (/exit, Ctrl+Z Enter, or Ctrl+C at the
prompt) it stops the marimo server it started.
- The server needs a token. Hailer gives every server it starts a random token and records it in
.hailer/kernel.json, so other programs on the machine cannot send code to the kernel. The link Hailer opens or prints carriesaccess_token=..., which signs the browser in. Links the agent quotes in the chat leave the token out, because tool results go to the model endpoint;/notebookprints the signed-in link. - Reuse. A server Hailer started for this workspace and left running (
--keep-marimo, or--foregroundin another terminal) is reused and left running. So is a marimo server you started yourself with a notebook from the folder open (local runtime only). - Log. marimo's output goes to
.hailer/marimo.log, emptied at each start. Hailer masks the token in the lines it prints from it. In docker mode the log isdocker logs hailer-kernel-<id>. - Where the code runs. By default the kernel runs in Hailer's own Python, as you. To run it in an
isolated container instead, add
--kernel dockeror see Isolated kernel (Docker).
The notebook opens in marimo's app view: you see the results, tables and charts the agent produces,
not the code behind them (the URL carries view-as=present). To see or edit the code, press Ctrl+.
(Cmd+. on macOS) in the notebook, or choose Toggle app view from the notebook menu; the same shortcut
switches back. It is a normal edit session either way, so the agent works in it exactly the same.
Flags: --port N (default 2718; a free port is chosen when it is busy), --no-browser (print the URL
instead of opening it), --keep-marimo (leave the server running after the chat; uvx hailer kernel stop
stops it later), --foreground (just run marimo attached to this terminal, no chat; it opens marimo's home
page unless --no-browser is given), --new (start a fresh conversation), --kernel local|docker (where
notebook code runs for this run; the default comes from [kernel] runtime).
Bare uvx hailer does the same as uvx hailer notebook with its defaults: it reuses this workspace's
marimo server or starts one, opens the notebook, chats, and stops only a server it started. A kept server
is found through .hailer/kernel.json:
uvx hailer
A pinned server ([hailer].marimo_url in hailer.toml, or HAILER_MARIMO_URL) is used as is, whichever
folder it serves. If it does not answer, Hailer says so and exits; it never starts a server in its place.
+--------------------- Hailer ----------------------+
| Model: gpt-5.5 |
| Provider: openai |
| Notebook: notebooks\analysis.py |
| Notebooks: notebooks |
| Workspace: C:\projects\hailer |
| Kernel: local (runs as you; not isolated) |
| Web access: none |
+---------------------------------------------------+
Type /help for commands.
You > what data do I have?
Hailer >
Six monthly sales files, 2025-01 to 2025-06, 400 orders each (discount appears from 2025-04).
I loaded them into one table, df, with a period column and added a summary to the notebook.
You > chart revenue by month and region
Hailer >
Revenue grew 8% from January to June, almost all of it in the North (+33%); the other regions are flat.
Added: revenue_by_month (table) and a line chart by region.
You > what is behind the North's growth?
Hailer >
Home (+101%) and Sports (+85%); Electronics fell 12%.
Added a category breakdown for the North with a bar chart.
You > exclude returns
Hailer >
Done. Returns were 2.7% of orders; the North is still up 29% without them.
The table and both charts now exclude returned orders.
You > which channel sells more?
Hailer >
Online: 55% of orders and 55% of revenue.
Added a channel comparison below the category chart.
Hailer resumes your previous conversation on the next start; use uvx hailer --new or /new for a fresh
thread. uvx hailer doctor runs the same startup checks and prints fixes.
Running inside abeam
abeam runs coding-agent CLIs in a terminal pane beside git status,
a file viewer and a shell. It starts hailer from your PATH, so install Hailer as a tool first:
uv tool install hailer
abeam +hailer
abeam forwards every argument, so abeam +hailer notebook --no-browser or abeam +hailer --new behave as
they do in a terminal. abeam starts Hailer in a git worktree; commit hailer.toml (and your notebooks) so
each worktree is a workspace of its own, since Hailer uses the nearest folder holding hailer.toml or
pyproject.toml. Each workspace then gets its own marimo server, .hailer/ stays out of the git pane, and a
pasted block of several lines arrives as one message.
Working with several notebooks
One analysis, one notebook: the agent can create a fresh notebook or go back to an earlier one, and you can do the same with slash commands. Everything happens on the one marimo server started on the notebooks folder, so nothing restarts.
In the chat, just say it:
You > start a new notebook for the Q2 churn review and load the churn files
You > open the regional sales notebook we did last week and add a chart by category
You > which notebooks do we have?
The agent lists the folder, creates notebooks/q2_churn_review.py (the name is slugified) or opens the
existing file, and the browser tab appears by itself. Whatever it switches to becomes the active
notebook: the one every later cell edit, /status line and hailer exec call refers to. The chat shows
Active notebook is now notebooks/q2_churn_review.py. when the agent switched.
The same from the prompt, without a model round-trip:
| Command | What it does |
|---|---|
/notebook |
Active notebook, folder, marimo state, the signed-in URL and the launch command. |
/notebook list |
Every notebook in the folder with active and open (has a kernel session) markers. |
/notebook new <name> [--empty] |
Create <slug>.py from the starter template (or an empty marimo notebook with --empty), open it, make it active. |
/notebook open <name> |
Switch to an existing notebook by name, filename or path; opens it in the browser when it has no session. |
/notebook close [name] |
Shut down that notebook's kernel session (the browser tab disconnects); it can be reopened any time. |
After a slash-command switch the next message you send carries a one-line notice such as
[Hailer] The active notebook is now notebooks/q2_churn_review.py (reopened, 7 cells). Call notebook_cells before editing. so the agent inspects the notebook before touching it. The conversation itself continues;
use /new if you want a clean thread as well.
Where things live:
- Notebooks go in
[hailer].notebooks_dir(default: the folder of[hailer].notebook, i.e.notebooks/). Only files in that folder can be opened; names are matched case-insensitively andq2 churn,q2_churn,q2_churn.pyandnotebooks/q2_churn.pyall mean the same file. - The starter template is the same set of cells as
notebooks/analysis.py(imports, thehailer.periodshelpers,WORKSPACE,DATA_DIR,data_files,period_files, a welcome cell with the notebook's title and the data files it found), so the agent can start analysing straight away.--emptygives marimo's plain empty notebook. - The active notebook is remembered in
.hailer/notebook.json(git-ignored, next tosession.json), so the nextuvx haileroruvx hailer notebookresumes where you left off. Delete the file to go back to[hailer].notebook.HAILER_NOTEBOOK=<path>makes that notebook the active one for this and later sessions: every Hailer command (hailer,hailer notebook,hailer exec,status,doctor) writes it to the state file at startup, so the agent's tools see the same notebook. /notebookalso lists the notebooks you worked in recently (Recent:), most recent first.
[hailer]
notebook = "notebooks/analysis.py" # the default (and first) notebook
notebooks_dir = "notebooks" # where /notebook new and the agent's notebook_create put files
Isolated kernel (Docker)
By default the notebook kernel runs in Hailer's own Python, as you: the code the agent writes can read and change every file your account can, and reach the network (see Security). If Docker is installed, you can run the kernel in a container instead. The agent, the conversation and the API key stay on your machine; only marimo and the notebook code move into the container.
local (the default) |
docker |
|
|---|---|---|
| Where marimo and notebook code run | Hailer's own Python, as you | A Linux container, as a non-root user |
| Files notebook code can read | Everything your account can | The notebooks folder and the data folder, nothing else |
| Files it can change | Everything your account can | The notebooks folder only; data is read-only |
| Network | Yours | None, unless network = true |
| Your environment variables | All except secret-looking names (see Security) | None |
| Needs | Nothing | Docker Desktop (Windows, macOS) or Docker Engine (Linux), and the kernel image |
Choosing the runtime
[kernel]
runtime = "docker" # "local" (default) or "docker"
# image = "" # docker: default ghcr.io/openafterhours/hailer-kernel:<hailer version>
# memory = "4g" # docker: memory limit, no swap on top
# cpus = 2 # docker: CPU limit (lowered to what Docker has, with a note)
# network = false # docker: true lets notebook code reach the internet, this machine and other containers
# pass_env = [] # local: secret-looking variables notebook code may still read (see Security)
-
At setup:
uvx hailer init --kernel dockerwrites ahailer.tomlwith this section switched on. Plainuvx hailer initwrites it commented out, and when Docker is found on the machine its next steps say how to switch it on.initnever rewrites an existinghailer.tomlwithout--force, and says so when--kernelcould not be applied. -
In an existing file: uncomment the
[kernel]line as well asruntime. Aruntimeline that ends up under[model]or[hailer]is ignored with a warning that says so. -
For one run:
uvx hailer notebook --kernel docker(or--kernel local), orHAILER_KERNEL=dockerin the environment. The flag wins over the variable, the variable over the file.HAILER_KERNEL_IMAGEoverridesimage. -
Which one is in use shows in the startup panel,
uvx hailer status,/statusanduvx hailer doctor(X.Y.Zis Hailer's version):Kernel: local (runs as you; not isolated) Kernel: docker (hailer-kernel X.Y.Z; no network; data read-only) Kernel: docker (hailer-kernel X.Y.Z; network on: the internet and this machine; data read-only)
A docker kernel Hailer started for this workspace is the one in use whatever the file says:
uvx hailerattaches to it when its mounted folders match and the line saysdocker. -
Docker mode fails closed. Docker missing or not running, Docker Desktop set to Windows containers, an image for another Hailer version, or a folder layout it refuses (below): Hailer stops and says how to fix it (see Troubleshooting). It never runs notebook code on this machine instead.
First run: the kernel image
The first uvx hailer notebook in docker mode downloads ghcr.io/openafterhours/hailer-kernel:<version>,
where the tag is Hailer's own version (about 200 MB to download, 900 MB on disk), with docker's progress
in the terminal. After that a start takes a few seconds (4.3 s measured on Windows 11 with Docker
Desktop). uvx hailer kernel pull downloads it ahead of time.
uvx hailer kernel buildbuilds the image on this machine instead, from the Dockerfile that ships inside Hailer, with the marimo, Polars and DuckDB versions this Hailer runs. It needs Docker Hub (the base image) and PyPI (the packages) once. Use it where the registry is out of reach, or for a Hailer version that has no published image (development versions never do).--tag <name>builds under another name; Hailer then says to setimageunder[kernel]to use it.[kernel].image(orHAILER_KERNEL_IMAGE) points Hailer at another copy, such as a company mirror.- The image's version label must equal Hailer's version, because a different marimo would break the notebook API Hailer drives. After upgrading Hailer, the next start downloads the matching image; a copy for another version stops the start with the pull and build commands.
What notebook code can and cannot do in the container
- Two folders. It sees the notebooks folder as
/work/notebooks(read-write, the only place files persist) and the data folder as/work/data(read-only). Nothing else of your machine: not the workspace,.hailer/,.config/hailer/, your home folder or the Docker socket. The data files are read in place through the mount, never copied. The starter notebook findsWORKSPACE = /workandDATA_DIR = /work/data, the model is told those paths, and notebooks need no changes. - No network. No internet, no DNS, no route to this machine. Installing packages (
ctx.packages.add()) and DuckDB'sINSTALLfail. The image has marimo, Polars, DuckDB, altair and plotly; anything else needs an image of your own (see Known limitations). - No secrets. None of your environment variables reach the container, and nothing of Hailer's own
runs in it but the notebook helpers (
hailer.periods) and the forwarder: no LangChain, no keyring, no API key. - Limits. A non-root user with no Linux capabilities, a read-only root file system, at most 256
processes, and the memory and CPU limits from
[kernel](memory without extra swap). On a Linux host the kernel runs with your user and group id, so files marimo saves stay yours. - Scratch space.
/tmpand the home folder are in memory, shared by every notebook in the container, and wiped when the kernel stops. - Cells run when a notebook opens, so the starter notebook's
DATA_DIR,data_filesandperiod_filesexist before anyone runs a cell.
network = true puts the kernel on Docker's default network with its port published directly on
127.0.0.1. Notebook code can then reach the internet, services on this machine (host.docker.internal)
and other containers. Everything else above still holds, and the Kernel: line says
network on: the internet and this machine.
Which folders can be mounted
In docker mode Hailer refuses a layout that would hand notebook code Hailer's own files, your
credentials, or a way to run code on this machine later. uvx hailer doctor reports it in the config
row, and every start stops on it, --foreground included (the same rules, in one place):
- Neither the notebooks folder nor the data folder may be, or contain, a drive root, your home folder,
the workspace folder,
.hailer/, the config file, or the context, skills and prompts folders. - Neither may be, contain or sit inside a folder that holds credentials:
~/.config,~/.ssh,~/.aws,~/.azure,~/.gnupg,~/.docker,~/.kube, and on Windows%APPDATA%and%LOCALAPPDATA%(the temporary folder inside it is fine). - The notebooks folder may not sit inside
.hailer/or those folders either, and the data folder may not sit inside the notebooks folder (it would become writable). - The notebooks folder may not contain
.gitorhailer.toml, including in subfolders: notebook code could change repository hooks or another workspace's settings. Keep notebooks in a plain subfolder of your repository, such asnotebooks/, and move nested repositories and workspaces outside that folder. Hailer checks names without following symlinks or junctions; an unreadable subtree stops startup. - The default layout,
notebooks/anddata/in the workspace, passes. The data folder can be anywhere else on the machine: set[hailer].data_dirto an absolute path and it is mounted read-only where it is. - Windows: a folder on a network share (a UNC path such as
\\server\share\sales) cannot be mounted: it is an error indoctor'sconfigrow and stops the start, with the same message. A mapped network drive gets a warning (doctorrowsdataandnotebooks): Docker Desktop usually cannot see it. Symlinks and junctions inside the data folder that point outside it do not resolve in the container;doctorlists them. Copy such data to a folder on a local disk. [hailer].marimo_url(orHAILER_MARIMO_URL) cannot be combined with docker: Hailer starts and finds its own containers.
Starting, reusing and stopping
- What runs.
uvx hailer notebookcreates three things, each labelled with the workspace:hailer-net-<id>, an internal network with no route out;hailer-kernel-<id>, the kernel, on that network only; andhailer-fwd-<id>, a small forwarder from the same image, published on127.0.0.1:<port>only, which carries the browser's and Hailer's requests to the kernel (Docker publishes no port for a container on an internal network).<id>comes from the workspace path, so two workspaces can run side by side. Withnetwork = truethere is only the kernel, published directly. When the chat ends, Hailer removes the kernel it started. Reused kernels are left running. - Reuse.
--keep-marimoleaves the kernel running. Bothuvx haileranduvx hailer notebookreuse it only when its mounted folders match. When Docker is configured, image, network, memory and CPU settings must also match; otherwise the command asks you to runuvx hailer kernel stopfirst.--kernel localcan attach to a running Docker kernel with matching folders. The chat keeps that server's token and path mapping in memory, including across notebook and model switches. --foregroundstarts the kernel and follows its log in this terminal. Ctrl+C stops and removes it; when it ends for another reason, Hailer says why (out of memory, removed from outside, exited).uvx hailer kernel stopstops this workspace's kernel, docker or local, and removes every container and network labelled with the workspace, running or not. It prints what it removed, says when Docker is not running (so leftovers could not be checked), and exits 1 when a recorded Docker kernel cannot be stopped or a removal failed (a container another terminal is removing at the same moment counts as removed, and a network whose containers are still detaching is retried for a few seconds). If Docker is unreachable, the recorded Docker kernel is kept for retry. An unanswered health check alone never proves those containers are gone.- Files the kernel may have planted. When a docker kernel stops (the chat ends,
--foregroundends,uvx hailer kernel stop), Hailer scans the notebooks folder and its subfolders for.git,.vscode,.idea,.devcontainerandhailer.tomland prints a loudWARNINGnaming them: notebook code can write there, and git or an editor would run commands from them. Delete them (unless you put them there yourself) before you run git in that folder or open it in an editor.uvx hailer doctorshows the same as anotebookswarning. Nothing is removed for you. - Leftovers. If Hailer itself is killed, the containers keep running and
.hailer/kernel.jsonstill records them, so the nextuvx hailer notebookoruvx hailerattaches to them anduvx hailer kernel stopremoves them. A start never removes a running kernel container that Hailer has no working record of, because it may still be in use: it stops and points atuvx hailer kernel stop. A start also refuses while the recorded kernel (docker or local) does not answer but is not provably gone (its containers or its process still exist: busy, stuck or suspended), and keeps its record souvx hailer kernel stopcan still find it. Stopped leftovers are cleaned up by the next start. - Logs.
docker logs hailer-kernel-<id>. A failed start prints its last lines.
Platforms
- Windows 11 with Docker Desktop (WSL2 engine, Linux containers): tested live and by the integration test.
- Linux: the integration test passes in WSL Ubuntu, and a CI job runs it on Ubuntu (Docker Engine) for every pull request.
- macOS: Docker Desktop. The release publishes the image for
linux/amd64andlinux/arm64(Apple Silicon), but the arm64 image is untested until the first release that publishes it. - Podman is not supported.
Model configuration
All model settings live in hailer.toml (repo root, or .config/hailer/hailer.toml). uvx hailer init
writes a commented starter file.
OpenAI
[model]
name = "gpt-5.5"
provider = "openai"
Store an API key once with uvx hailer login openai, or set OPENAI_API_KEY in the terminal. Hailer
talks to https://api.openai.com/v1 over the Responses API. To use Chat Completions instead, or to turn
streaming off, declare the provider without a base_url:
[model_providers.openai]
wire_api = "chat"
A custom or internal endpoint
Any endpoint that speaks the OpenAI Responses API or Chat Completions works: an internal gateway, Azure OpenAI, LiteLLM, vLLM, Ollama and so on.
[model]
name = "analyst-v3" # whatever model id the endpoint expects
provider = "internal"
# reasoning_effort = "medium" # minimal | low | medium | high | xhigh; "" sends no reasoning effort
# summarize_after_tokens = 100000 # summarise older turns past this size; lower it for small context windows
[model_providers.internal]
base_url = "https://llm.example.internal/v1"
wire_api = "responses" # or "chat" for a Chat Completions endpoint
env_key = "INTERNAL_MODEL_API_KEY" # env var name; value from `hailer login internal` or the terminal
# stream = true # false if the endpoint rejects stream = true
# stream_options = true # false omits stream_options (token counts may be lost)
# name = "Internal"
# http_headers = { "X-Team" = "data-analytics" }
# env_http_headers = { "X-Client-Id" = "INTERNAL_CLIENT_ID" }
# query_params = { "api-version" = "2025-04-01-preview" }
Then:
uvx hailer login internal # stores the key in the OS credential store (hidden prompt)
uvx hailer doctor # config / notebook / credentials / marimo / session, with fixes
uvx hailer
Or set INTERNAL_MODEL_API_KEY in the terminal instead of logging in; an environment variable always wins
over the credential store. The startup panel and status show the provider with its base URL; doctor and
status show where the key came from (from keyring or from env), never the value.
wire_api chooses the protocol the endpoint speaks:
wire_api |
What Hailer sends | Use it when |
|---|---|---|
"responses" (default) |
POST {base_url}/responses |
The endpoint implements the OpenAI Responses API. |
"chat" |
POST {base_url}/chat/completions |
The endpoint implements Chat Completions (vLLM, Ollama, LiteLLM, most internal gateways). |
Hailer sends the request itself, straight to base_url, so what the endpoint sees is small and predictable:
the key as Authorization: Bearer ..., your http_headers, each env_http_headers entry whose variable is
set, your query_params, and a body with model, messages (one system message with Hailer's
instructions, then the conversation), tools (Hailer's eleven tools as ordinary functions) and stream.
Every request names the model you configured; there are no side requests under other model names. The
endpoint must support function calling. HTTP_PROXY, HTTPS_PROXY and NO_PROXY are honoured, and TLS uses
the operating system's certificate store, so a company root certificate installed on the machine works.
Two per-provider switches help with strict endpoints; both default to true and apply to either
wire_api:
stream = falsesendsstream: falseand reads one JSON reply, for endpoints that reject streamed requests or cannot deliver server-sent events (some gateways and proxies buffer or refuse them). The answer then appears when the turn finishes rather than as it is generated. It is also the workaround for an endpoint that streams tool calls in a shape the client does not understand. The provider shows asinternal (https://..., chat completions, no streaming)in the startup panel and/status.stream_options = falseomits thestream_optionsfield from streamed requests (some Azure API versions and proxies reject it). Token counts are then whatever the final chunk carries, often nothing.
Independently of the provider, reasoning_effort = "" under [model] stops the reasoning effort being
sent at all, for endpoints or models that reject it.
Long conversations are kept inside the model's context window by summarising older turns once the
conversation passes [model].summarize_after_tokens (default 100000; the last 20 messages are always kept
as they are). The summary is written by the model you configured. Lower the number for a model with a
small context window; 0 turns summarising off.
Several providers can be declared; switch inside a session with /model <name> or
/model <provider>:<name> (this starts a new thread). One-off overrides: HAILER_MODEL,
HAILER_MODEL_PROVIDER.
Secrets
uvx hailer login <provider> stores the API key in the operating system's credential store through
keyring (Windows Credential Manager on Windows) under the service name hailer. Hailer reads it when the
agent starts and hands it to the HTTP client inside its own process, which sends it only as the
Authorization header of requests to that provider's endpoint. It is never written to hailer.toml, the
session file, command history or logs, and never passed on a command line or to another process.
uvx hailer logout <provider> removes it.
An environment variable named by env_key takes precedence over the credential store, which keeps scripted
and CI use simple. Hailer leaves that variable (and other secret-looking ones) out of the environment of the
marimo server it starts, so it is not in notebook code's environment (see Security). Note that any
process running as the same user can read both credential-store entries and environment variables; the
gain is keeping the key out of files and history, not isolation from your own account.
Every provider needs a key, including the built-in openai one. A missing key stops hailer at startup
with the login command to run.
Allowed web domains
By default the agent has no internet access: it has no shell, and its fetch_page tool refuses every
URL. To let it read specific sites for context:
[web]
allowed_domains = ["docs.pola.rs", "duckdb.org", "**.marimo.io"]
max_page_bytes = 200000
- Exact hosts match themselves;
*.example.commatches subdomains only;**.example.commatches the apex and its subdomains. Matching is case-insensitive and ignores ports. Loopback and private addresses are only allowed when listed literally (for example127.0.0.1). fetch_pagechecks the host before any network I/O and on every redirect, converts HTML to readable text, caps the size, and returns the text to the model. A refused URL tells the agent which domains are allowed so it can ask you to extend the list.
Text fetched from an allowed site becomes a tool result and is sent to the model endpoint like any other
result. /context, /status and the startup panel show the active allowlist. The allowlist governs the
agent's own web tool, not what notebook code can do: with the default local kernel, code the agent runs
in the notebook is ordinary Python on your machine with your network access; with the docker kernel it
has no network at all unless [kernel] network = true (see Security).
Project context, skills and prompts
.config/hailer/ holds what the agent knows about this project. Commit it with the notebook.
.config/hailer/
├── context/ always-on: every *.md here is sent to the model at the start of each session
├── skills/ on-demand: <name>/SKILL.md (+ reference/, scripts/) in the Agent Skills format
└── prompts/ reusable prompts: /prompt <name> [args] ({{args}} is substituted)
- context/: short Markdown files with column meanings, conventions and house style, loaded in filename
order and concatenated up to
max_context_bytes. Hailer warns at startup if a file looks like it contains a key or token. - skills/: only the
nameanddescriptionfrom eachSKILL.mdfrontmatter go into the agent's instructions; the body and bundled files are loaded when the task matches (the agent callsload_skillandread_skill_file) or when you type/skill <name> [message]. - prompts/:
/prompt first-look customers.csvsendsprompts/first-look.mdwith{{args}}replaced. /contextlists what is loaded;/reloadre-reads the folder and applies from your next message, in the same conversation.
Everything in context/, and any skill you load, is sent to the configured model endpoint. Keep
credentials, customer names and row-level data out of it.
Slash commands
| Command | What it does |
|---|---|
/help |
Show the command list. |
/status |
Model, provider, credentials source, thread id, token usage, marimo state, kernel runtime, web allowlist. |
/new |
Start a new conversation thread (context files are re-read). |
/model <name> or /model <provider>:<name> |
Switch model (and provider); starts a new thread. |
/notebook, /notebook list, /notebook new <name> [--empty], /notebook open <name>, /notebook close [name] |
Show or switch the active notebook (see Working with several notebooks). |
/context |
List loaded context files, skills, prompts and the web allowlist. |
/skill <name> [message] |
Run a turn with a project skill attached. |
/prompt <name> [args] |
Send a saved prompt from .config/hailer/prompts. |
/reload |
Re-read .config/hailer; applies from your next message. |
/clear |
Clear the screen. |
/exit, /quit |
Exit Hailer (Ctrl+C at the prompt, Ctrl+D on an empty line, or Ctrl+Z then Enter, also exit). |
Interactive chat keeps a framed input box below the conversation. Your submitted messages, Hailer's replies and command results appear above it. The context line shows the active notebook, model and number of loaded context files; the activity line shows what Hailer is doing.
Enter sends; Alt+Enter inserts a newline. Pasting several lines keeps them in one editable message. Up and Down move through multiline input and recall earlier messages at its boundaries (history stays in memory for this session). You can draft your next message while Hailer works; Enter preserves that draft until the current operation finishes or is cancelled. Requests are not queued.
The interface uses normal terminal scrollback and text selection. It does not capture the mouse or
switch to an alternate screen. Use hailer --plain or hailer notebook --plain for the line-oriented
You > interface. Pipes and terminals reporting TERM=dumb or TERM=unknown use plain input automatically.
Ctrl+C during a turn cancels the model request and keeps the composer open. A notebook command already
performing a blocking operation finishes its cleanup before another command can start. Cancellation does
not undo tool actions that have already happened. Ctrl+C while idle exits; Ctrl+D on empty input or
Ctrl+Z then Enter also exits. The conversation is stored in .hailer/threads.sqlite, which is how
hailer resumes it after a restart; /new (or hailer --new) discards it and starts another.
Completed replies are displayed once, with Markdown formatting; live activity does not print interim
model commentary. /clear clears the display without resetting the conversation.
Other subcommands:
| Command | What it does |
|---|---|
hailer notebook [--port N] [--no-browser] [--keep-marimo] [--foreground] [--new] [--plain] [--kernel RUNTIME] |
The one-command session described in Quick start; marimo runs on the notebooks folder. RUNTIME is local or docker. |
hailer exec -c "code" (or hailer exec script.py, hailer exec - for stdin) |
Run Python in the active notebook's kernel yourself. |
hailer status, hailer doctor |
Configuration and state; the startup checks with fixes. |
hailer login <provider>, hailer logout <provider> |
Store or remove a provider's API key. |
hailer init [--force] [--kernel RUNTIME] |
Set up a workspace (see Quick start); --kernel writes [kernel] runtime. |
hailer kernel pull |
Download the kernel image for this Hailer (or [kernel].image). |
hailer kernel build [--tag <name>] |
Build the kernel image on this machine. |
hailer kernel stop |
Stop this workspace's kernel (local or docker) and remove its containers and network. |
Global options go before the subcommand: --verbose, --config <path>, --workspace <path>, --new,
--plain, --version (for example uvx hailer --workspace C:\projects\sales kernel stop).
Data conventions
Any CSV, Parquet or JSON file in data/ can be analysed, whatever it is called: the starter notebook lists
them in data_files, and the agent loads them with Polars or queries them with DuckDB when you ask.
One naming convention is optional. When the same dataset arrives every month, name the files
YY-MM <dataset>.parquet, for example 25-03 sales.parquet for March 2025. The data itself then needs no
period column; Hailer derives it from the filename, combines the months into one table and copes with
columns that only appear in later months.
hailer.periods (imported by the starter notebook, so the agent reuses it):
| Function | Purpose |
|---|---|
list_data_files(data_dir) |
Every data file (CSV, Parquet, JSON, ...) directly in the folder, whatever its name, sorted by name. |
parse_period(name) |
"25-03 sales.parquet" → Period(2025, 3); None if the name does not match. |
scan_period_files(data_dir, name=None) |
Sorted PeriodFiles for one dataset (or all) in a folder. |
load_periods(files, columns=None) |
One Polars DataFrame with a leading period column (YYYY-MM); schema evolution handled with a relaxed diagonal concat. |
scan_periods(files) |
Lazy variant of load_periods. |
duckdb_periods_view(con, files, view_name="periods") |
Registers a DuckDB view over all files (union_by_name) with period derived from the filename. |
describe_periods(files) |
Compact text: months found, common columns, columns present only in some months. |
A monthly file that cannot be read raises MalformedParquetError naming the file. The starter notebook
(notebooks/analysis.py) defines WORKSPACE, DATA_DIR, data_files and period_files and shows a
table of the data files it found.
Logging
Normal runs print only warnings. uvx hailer --verbose (or HAILER_LOG_LEVEL=DEBUG) logs provider,
session, marimo and tool activity and shows full tracebacks. Log output passes through a redaction filter
that masks bearer tokens and the values of environment variables whose names end in _KEY, _TOKEN,
_SECRET or _PASSWORD.
Tests
uv run pytest
The suite is offline: it needs no API key, no marimo server, no model endpoint, no network and no Docker.
The marimo protocol is exercised against a local fake server (tests/fake_marimo.py, token-checked like a
server Hailer starts), the agent against a scripted chat model and against a strict Chat-Completions-only
fake gateway on loopback (tests/fake_gateway.py, which rejects unknown request fields and model names the
way internal gateways do), and the credential store against an in-memory backend. The kernel runtimes are
tested without starting anything: tests/test_kernel.py (path map, kernel.json, the kernel's
environment, the local runtime with its processes faked), tests/test_kernel_docker.py (the docker runtime
against tests/fake_docker.py, a scripted docker CLI that keeps containers and networks with ids,
labels and --filter, like Docker 29), tests/test_kernel_image.py, tests/test_forward.py (the forwarder
on real loopback sockets) and tests/test_build_kernel_image.py. tests/fake_kernel.py holds the shared
pieces.
tests/test_docker_integration.py drives the real docker runtime: it starts a kernel for a temporary
workspace with sample sales data, opens the notebook in headless Chrome, and checks that code runs as a
non-root user in /work, a code-mode cell lands in the host notebook, writes to the data folder and the
network are refused, no host secret or the server token is visible, and stopping leaves nothing behind.
It is opt-in and never pulls the image:
uv run python -m scripts.build_kernel_image --load # the image for this checkout, into local Docker
HAILER_DOCKER_TESTS=1 uv run pytest tests/test_docker_integration.py -rs
(In PowerShell, set the variable first: $env:HAILER_DOCKER_TESTS = "1".)
HAILER_DOCKER_TESTS=1 skips, with the reason, when Docker, the image or a browser is missing;
HAILER_DOCKER_TESTS=strict fails instead. HAILER_TEST_CHROME picks the browser (otherwise
google-chrome or chromium on PATH, or Chrome's usual install folder on Windows and macOS), and
HAILER_KERNEL_IMAGE another image.
.github/workflows/test.yml runs the suite on Ubuntu and Windows with Python 3.12 and 3.13 for every
push to main and every pull request; the repository requires those four checks by name. A fifth job,
Docker kernel, builds the image from the checkout on Ubuntu and runs the integration test with
HAILER_DOCKER_TESTS=strict (Linux only: GitHub's Windows runners only run Windows containers). It is
not a required check, but the release waits for it.
Releasing
uv run python -m scripts.release # patch bump: 0.1.0 -> 0.1.1
uv run python -m scripts.release minor # 0.1.0 -> 0.2.0
uv run python -m scripts.release major # 0.1.0 -> 1.0.0
uv run python -m scripts.release --dry-run # preflight checks and the plan, nothing changed
Run it from a clean main that matches origin/main. The script writes the new version to pyproject.toml,
src/hailer/__init__.py and uv.lock, then runs the test suite. If the tests fail, the version files are
restored and nothing is committed. If they pass, it commits Release vX.Y.Z, tags vX.Y.Z and pushes the
branch and tag atomically. The tag triggers .github/workflows/release.yml, which:
- runs every job in
test.ymlagain (the four platforms and the Docker kernel job) and builds the sdist and wheel; - builds the kernel image for
linux/amd64andlinux/arm64withscripts/build_kernel_image.pyand pushes it asghcr.io/openafterhours/hailer-kernel:X.Y.Z(the job checks that the tag,pyproject.tomlandhailer.__version__agree, since the image's version label must match the package); - only when all of that succeeds, publishes to PyPI through the
pypienvironment (trusted publishing, no token to store) and creates the GitHub release with the files attached. The image goes first so no released Hailer points at a missing image.
--no-push stops after the local commit and tag, --version X.Y.Z releases an exact version (pre-releases
such as 1.2.0rc1 are accepted), and arguments after -- are passed to pytest.
One-time step for the kernel image. GHCR creates the hailer-kernel package as private on the first
push. After the first release, make it public in the organisation's package settings
(github.com/orgs/OpenAfterHours/packages, hailer-kernel, Package settings, Change visibility);
otherwise uvx hailer kernel pull and the first docker start fail for everyone outside the organisation
(they see The kernel image for Hailer X.Y.Z is not published (or not visible to you)). If a
hailer-kernel package was ever pushed by hand, also give this repository the Write role under Manage
Actions access on the same page, or the workflow's push is refused.
Development versions have no published image. Build one for the checkout into your local Docker with
uv run python -m scripts.build_kernel_image --load. It uses the build context hailer kernel build and
the release use (the packaged Dockerfile and the hailer package this checkout runs); --dry-run prints
the docker buildx build command, --tag renames the image, and --help lists the rest.
Repository rulesets restrict this: main cannot be force-pushed or deleted and changes to it must come
through a pull request with the test checks green, and v* tags can only be created by repository admins,
who also bypass the pull-request rule so the release script can push directly. The pypi environment only
deploys from v* tags, and .github/workflows/members-only.yml closes pull requests opened from forks by
people outside the OpenAfterHours organization. Open your own pull requests from a branch in this repository;
those are always kept.
Security
Sent to the configured model endpoint: your messages; Hailer's system prompt plus everything in
.config/hailer/context; the name and description of each skill, and a skill's full body once loaded; every
tool call argument (that is, the Python the agent writes) and the tool results, truncated to
max_tool_output_chars; and pages fetched from allowed domains. The instructions name the workspace,
notebooks and data folders by path (with the docker kernel, the container's /work paths). With the openai provider this goes to OpenAI; with a custom provider,
to the base_url you configured, and nowhere else: when older turns are summarised, the same endpoint and
model write the summary.
Not sent: your data files or any dataframe, unless code explicitly prints or returns it (the agent is instructed to inspect schemas, samples and aggregates and to keep outputs compact); API keys or other secrets (the key stays inside the Hailer process and is masked in logs).
What the agent can do: it has Hailer's eleven tools and nothing else: no shell, no file tools, no
tools from other software on your machine. Nothing asks for approval before a tool runs. The tool that
matters is marimo_execute: it runs the Python the model writes in the notebook kernel. That is what
makes the analysis possible. What the code can reach depends on the kernel runtime:
-
local(the default): not sandboxed. The kernel runs in Hailer's own Python, as you, with your file and network access. Hailer closes two gaps around it:- Other programs. The marimo server Hailer starts requires a random token, handed to marimo on stdin
(never on a command line) and kept in
.hailer/kernel.json, so another program on the machine cannot send code to the kernel. A marimo server you start yourself with--no-tokenhas no such protection. - Secrets in the environment. The server's environment leaves out every provider's
env_keyandOPENAI_API_KEY, the variables named inenv_http_headers,HAILER_MARIMO_TOKEN, the namesPASSWORD,SECRET,TOKEN,PGPASSWORDandMYSQL_PWD, and every name ending in_KEY,_TOKEN,_SECRET,_PASSWORD,_PASSWD,_PWD,_CREDENTIALS,_CONNECTION_STRINGorAPIKEY(any case).uvx hailer doctorshows how many were withheld. A notebook that needs one of them (a database password, say) gets it through[kernel] pass_env = ["DB_PASSWORD"]; naming a provider's key or header variable, orHAILER_MARIMO_TOKEN, there is aconfigwarning.
Notebook code can still read the OS credential store and every file you can, which is why the
Kernel:line saysnot isolated. - Other programs. The marimo server Hailer starts requires a random token, handed to marimo on stdin
(never on a command line) and kept in
-
docker: isolated. The kernel runs in a container that sees only the notebooks folder (read-write) and the data folder (read-only), with no network, none of your environment variables, as a non-root user with resource limits (see Isolated kernel (Docker)). Hailer refuses to mount a folder that would expose its own files or your credentials (~/.ssh,~/.aws,%APPDATA%, ...), a data folder inside the notebooks folder, and a notebooks folder that is a git repository, on every start path (see Which folders can be mounted).
The instructions forbid destructive file operations and sending data anywhere, but instructions are not an enforcement boundary: use an endpoint and model you trust, keep the data folder to data the agent may read, and press Ctrl+C if a turn goes somewhere you did not intend.
What the docker runtime does not protect against:
- Output goes to the model. Anything notebook code prints or returns is a tool result and is sent to the model endpoint, in either runtime.
- Notebooks are code. A notebook the container wrote runs on your machine, as you, if it is later opened with the local runtime (or with marimo directly). Hailer warns on the first local start after a docker kernel used the notebooks folder. Keep the notebooks folder inside your project's git repository (a plain subfolder, not a repository of its own), so every change is a diff you can review.
- Other files in the notebooks folder can run code later. Notebook code can write anything into the
notebooks folder, including a
.gitfolder (hooks and settings such ascore.fsmonitorrun when git or an editor that scans nested repositories, like VS Code, touches it),.vscode,.ideaor.devcontainersettings, or ahailer.tomlthat makes the folder look like another workspace. Hailer refuses existing repositories and nested Hailer workspaces anywhere in the notebooks tree. At stop and indoctor, it warns about repository, editor and workspace controls in that tree. These checks do not prevent an editor from acting on a new file while the kernel is running. Disable automatic repository discovery and automatic editor tasks for folders holding untrusted notebooks. Review new control files before opening them; Hailer never removes them for you. - The token is on the container's command line, so
docker inspectshows it. Anyone who can use Docker on the machine can alreadydocker execinto the container, so hiding it would gain nothing. - The image is trusted by name. Hailer checks its tag and version label, not a signature, and runs
whatever
[kernel].imagenames. The image pins marimo, Polars, DuckDB, altair, plotly and its base image, but not their dependencies. - It is a choice, not a policy. Anyone can switch back to
local. An organisation that must enforce isolation should run Hailer itself in a managed virtual machine or dev container.
Links and tokens: the notebook link Hailer opens in your browser, and every link the CLI prints
(/notebook, hailer status, doctor), carries access_token=<token>, which signs the browser in. It
works like a password for the kernel while the server runs, so do not paste it anywhere. URLs in tool results
leave the token out, because tool results go to the model endpoint; when the agent asks you to open a
notebook, run /notebook for the signed-in link.
Tracing: Hailer's agent library (LangChain) can send full conversation traces to the LangSmith service
when variables such as LANGSMITH_TRACING are set in the environment. Hailer switches that off for its own
process at startup, so a variable left over from another project cannot send your prompts and results to a
third party. Set HAILER_TRACING=1 if you do want the tracing variables in your environment to apply.
On disk: the conversation (your messages, the agent's replies, tool calls and their truncated results)
is stored unencrypted in .hailer/threads.sqlite inside the workspace until /new or hailer --new
replaces it. .hailer/kernel.json records the marimo server Hailer started (runtime, URL, token and, for
docker, the container and network ids and the settings it was started with) and is deleted after successful cleanup; failed Docker cleanup keeps the record for retry; .hailer/last-kernel.json notes which runtime last used the notebooks folder. .hailer/marimo.log
holds the local server's output, including its signed-in URL, and is emptied at each start. On macOS and
Linux these three files are readable by you only. .hailer/ is never mounted into a docker kernel and is
kept out of git by its own .gitignore containing *; Hailer never edits your repository's .gitignore.
Troubleshooting
uvx hailer doctor runs the startup checks (config, notebook, credentials, kernel, marimo, session; in
docker mode also docker, image, data and, when there is something to say, notebooks) and, when a session
exists, confirms that marimo's code-mode API and Hailer's notebook helpers (hailer.periods) are
available in the kernel.
| Message | Meaning and fix |
|---|---|
No marimo server is running for this workspace. (doctor or exec; status says none for this workspace) |
Run uvx hailer or uvx hailer notebook to start or reuse this workspace's kernel. Hailer finds its own servers through .hailer/kernel.json; local registry servers must serve this notebooks folder. Docker mode never attaches to a local server. |
Marimo is not running at <url>. with a hint about [hailer].marimo_url |
The pinned URL does not answer. Start marimo there or remove the setting; Hailer never starts a replacement for an explicit pin. |
Could not finish stopping the Docker kernel or Could not stop the Docker kernel |
The record is kept and the command exits 1. Restore access to Docker and run uvx hailer kernel stop again. |
Could not record the Docker kernel or Could not record the local kernel |
Writing .hailer/kernel.json failed. Hailer stops what it started; check disk space and permissions before retrying. |
Marimo exited early (code N) or Marimo did not answer on http://127.0.0.1:2718 within 60 s, followed by Log: <workspace>\.hailer\marimo.log and its last lines |
hailer notebook could not start marimo. The log tail usually names the cause (port in use by something else, a syntax error in the notebook, marimo not installed in the environment). |
the notebook is not open in a browser followed by Open http://... in your browser. |
The server is up but has no kernel session. Open the URL; Hailer opens it for you once at startup. The URL ends in &view-as=present (app view) and, for a server Hailer started, &access_token=...; Ctrl+. in the notebook shows the code. |
| The browser shows marimo's sign-in page instead of the notebook | The link has no access_token: links in the agent's replies and tool results leave the token out, because they go to the model endpoint. Run /notebook in the chat for the signed-in link. |
Docker is not installed. |
Docker mode needs the docker command. Install Docker Desktop (Windows, macOS) or Docker Engine (Linux) and run the command again, or set [kernel] runtime = "local" to run notebook code on this machine without isolation. |
Docker is not running. |
The docker command is there but the engine does not answer (Docker Desktop is closed or still starting). Start Docker Desktop (or the Docker service), wait until it is running, and run the command again. uvx hailer kernel stop still stops a local server and says that it could not check for containers. When the hint says The docker command cannot reach an engine through its current context, DOCKER_CONTEXT or docker context use names a context that does not work: docker context use default switches back. |
Docker runs windows containers; Hailer's kernel image needs Linux containers. |
Docker Desktop is set to Windows containers. Choose Switch to Linux containers from its tray icon menu, or set [kernel] runtime = "local". |
The kernel image for Hailer X.Y.Z is not published (or not visible to you): ghcr.io/openafterhours/hailer-kernel:X.Y.Z. |
The registry refused the download: this version has no published image (a development version, or a release whose image job has not finished), or the image is not public. uvx hailer kernel build builds it on this machine; or set [kernel].image (or HAILER_KERNEL_IMAGE) to a copy you can reach, such as a company mirror. Could not download the kernel image ..., with docker's own error above it, is a network, proxy or registry problem with the same fixes. |
The kernel image <image> is for Hailer A.B.C; this is Hailer X.Y.Z. (or (no version label)) |
[kernel].image names an image built for another Hailer, and a different marimo would break code mode. uvx hailer kernel pull downloads the matching one, uvx hailer kernel build builds it; or point image at the matching tag. |
The notebooks folder (<path>) is the workspace folder. With [kernel] runtime = "docker" it is mounted writable into the container, so notebook code could change Hailer's own files. (also is a whole drive, contains your home folder, is inside Hailer's .hailer folder, is inside C:\Users\<you>\.aws, a folder that holds credentials, the same for The data folder, and [hailer].data_dir (...) is inside the notebooks folder) |
Docker mode refuses, on every start (--foreground too), a mount that would hand notebook code Hailer's own files or your credentials (see Which folders can be mounted). Keep the notebooks and the data in folders of their own, such as notebooks/ and data/, and point [hailer].notebooks_dir and [hailer].data_dir at them. |
The notebooks folder (<path>) is a git repository (it has .git at its top level). or contains repository or workspace controls: ... |
In docker mode notebook code could add git hooks or settings (core.fsmonitor) there that run on this machine the next time git or an editor touches the repository. Keep the notebooks in a plain subfolder of your repository (such as notebooks/), or remove the nested repository. |
WARNING: the notebooks folder <path> contains .git, project/.vscode. (any of .git, .vscode, .idea, .devcontainer, hailer.toml), when a docker kernel stops or in doctor |
The docker kernel can write to the notebooks folder, and git, editors and Hailer read these. Unless you put them there yourself, delete them before you run git in that folder, open it in an editor, or run Hailer from inside it. Hailer never removes them for you. |
The data folder \\server\share\sales is on a network share (UNC path), which Docker cannot mount. (an error in doctor's config row and at every start), or in doctor: the container may not see all of the data folder with ... is on a mapped network drive (Z:); Docker Desktop usually cannot mount it. (a warning) |
Docker Desktop cannot mount network locations. Copy the data to a folder on a local disk and point [hailer].data_dir at it (the same for the notebooks folder and [hailer].notebooks_dir). N link(s) in the data folder point outside it means symlinks or junctions the container cannot follow: copy those files into the folder. |
The running kernel was started with other settings (memory 4g, hailer.toml: 2g). |
A kernel left running (--keep-marimo, --foreground) was started with other folders, network, image, memory or cpus than hailer.toml asks for now, so uvx hailer notebook does not reuse it. Run uvx hailer kernel stop, then the command again. Bare uvx hailer follows the same reuse rules. |
A kernel container for this workspace is still running, but Hailer cannot reach it: hailer-kernel-<id>. |
A docker kernel from an earlier run (a crash, a closed terminal) that Hailer has no working record of. Hailer never removes a running kernel on its own, since it may be in use. uvx hailer kernel stop removes it. |
A docker kernel Hailer started for this workspace is already running at <url>. or A local marimo server Hailer started for this workspace is still running at <url>. |
Starting another server would orphan the running one (a second --foreground, or --kernel docker while a local server Hailer started is still up). uvx hailer notebook and uvx hailer attach to a running docker kernel. Otherwise end the session that started it, or run uvx hailer kernel stop. |
A docker kernel Hailer started for this workspace (hailer-kernel-<id>, hailer-fwd-<id>) may still be running, but it does not answer at <url>. (or A local marimo server ... (process N) may still be running, ...) |
The recorded kernel does not answer, but its containers or its process still exist (busy, stuck or suspended), so a new start would orphan it. Wait and try again, or run uvx hailer kernel stop. For a local server whose process still runs, kernel stop clears the record and says so (Process N is still running: ...); end that process yourself if it is the stuck server. |
marimo stopped (exit code N): it was ended from outside this terminal (uvx hailer kernel stop, Task Manager or kill), or it failed; its own output is above. |
Printed by a local --foreground when its marimo ended without Ctrl+C. |
The kernel container exited early (code N)., The forwarder container exited early (code N). or Marimo did not answer on http://127.0.0.1:<port> within 60 s., followed by Last lines of docker logs hailer-kernel-<id>: |
The docker kernel did not come up; the log lines usually name the cause. Nothing is left running. |
The kernel ran out of memory and Docker stopped it; [kernel].memory in hailer.toml raises the limit. |
Printed by --foreground. Raise [kernel].memory (for example "8g") and start again. In a chat the same event shows as marimo no longer answering. |
Note: [kernel].cpus = 8 is more than the 4 CPUs Docker has; the kernel gets 4. |
Information only. Docker Desktop's resource settings decide how many CPUs its engine has. |
A notebook that used to work fails with KeyError: 'DB_PASSWORD' (or a database refuses a login) in the local runtime |
Hailer withholds secret-looking environment variables from the local kernel (doctor shows how many). Name the ones the notebook needs in [kernel] pass_env = ["DB_PASSWORD"]. A docker kernel gets no environment variables at all, and pass_env does not change that. |
The running kernel cannot see <notebook>: it mounts other folders. |
The active notebook is outside the folders the running docker kernel mounted (the notebooks folder changed after it started), so the link opens marimo's home page instead. Keep notebooks in the notebooks folder, or run uvx hailer kernel stop so the next start mounts the current folders. |
Warning: [model].runtime in <path> is ignored: runtime belongs under [kernel]. |
The [kernel] line is still commented out, so runtime landed in the table above it. Uncomment [kernel] as well (or try docker once with uvx hailer notebook --kernel docker). |
[hailer].marimo_url (or HAILER_MARIMO_URL) cannot be used with [kernel] runtime = "docker" |
Hailer starts and finds its own container in docker mode. Remove marimo_url, or set runtime = "local" to use that server. |
Invalid [kernel].runtime "..." (or HAILER_KERNEL) or --kernel must be "local" or "docker"; also Invalid [kernel].memory / cpus / pass_env |
Fix the value: runtime is "local" or "docker", memory a size such as "4g" or "512m", cpus a number above 0, pass_env a list of variable names. |
warn notebook helpers: hailer.periods did not import in the kernel |
The kernel's Python lacks Hailer's notebook helpers, which the starter notebook imports. In docker mode [kernel].image names an image without them: uvx hailer kernel pull or uvx hailer kernel build gets the right one. |
Could not remove hailer-kernel-<id>: docker said: ... after uvx hailer kernel stop (exit code 1) |
Docker refused a removal and the container (or network) is still there. Run uvx hailer kernel stop again; if it keeps failing, remove the one named in the message with docker rm -f (or docker network rm). |
Warning: [kernel].pass_env lets notebook code read CORP_API_KEY (the API key of provider "corp"); ... |
pass_env names one of Hailer's own secrets (a provider's key or header variable, or HAILER_MARIMO_TOKEN), so code the model writes could use or leak it. Remove it unless notebooks truly need it. |
not found: <path> for the notebook |
The configured notebook ([hailer].notebook, or HAILER_NOTEBOOK) does not exist. uvx hailer init creates it from the starter template (your hailer.toml is kept), or fix the path; a deleted active notebook is not the cause, because Hailer already falls back to the configured one when the remembered notebook is gone. New notebooks are created from the chat with /notebook new <name>. |
No notebook named '...' in notebooks. or ... is outside the notebooks folder. |
/notebook open (or the agent's notebook_open) only opens marimo notebooks inside [hailer].notebooks_dir; the hint lists the available names. Move the file into the folder or point notebooks_dir at it. |
INTERNAL_MODEL_API_KEY is not set (required by provider 'internal'), or the same for OPENAI_API_KEY and provider 'openai' |
Every provider needs a key. Run uvx hailer login <provider> or set the variable in this terminal. Releases up to 0.2.2 could use a ChatGPT login for the openai provider; that is gone, so create an API key. |
The model endpoint rejected the API key for provider '...' |
The endpoint returned 401. Re-run hailer login <provider>. |
Unknown model '...' for provider '...' |
The endpoint does not know [model].name (or the name given to /model). Its reply is quoted after The endpoint said:. |
Could not reach the model endpoint at <base_url> |
Check base_url, VPN or proxy (HTTP_PROXY, HTTPS_PROXY, NO_PROXY), and that the endpoint is running. |
The endpoint at <base_url> did not accept the request. |
The endpoint answered 404/405. The hint names the request Hailer sent (POST <base_url>/responses or POST <base_url>/chat/completions): either base_url is wrong, or the endpoint implements the other API, in which case switch wire_api. The endpoint's own reply is quoted on the last line, after The endpoint said:. |
The endpoint at <base_url> rejected the request (HTTP 400) (or 422) |
The endpoint understood the request but refused a field or a value. The line The endpoint said: quotes its reply: fix what it names (stream = false or stream_options = false on the provider, reasoning_effort = "" under [model] to stop sending reasoning effort). hailer --verbose logs the HTTP traffic. |
The endpoint at <base_url> refused access (HTTP 403). |
The key was accepted but is not allowed for this model, route or organisation. Check the endpoint's access policy and the provider's http_headers / env_http_headers. |
The endpoint at <base_url> is unavailable (HTTP 429) (or 5xx) |
Rate limit or an outage on the endpoint's side. Retry in a moment. |
The conversation no longer fits the model's context window. |
Start again with /new, and lower [model].summarize_after_tokens so older turns are summarised before the model's limit is reached. |
Warning: unknown key [model_providers.x].merge_messages ... is ignored (also parallel_tool_calls, requires_openai_auth, [hailer].codex_home, [web].allow_shell_network) |
Settings from releases up to 0.2.2 that no longer mean anything. The file still loads; delete the keys to silence the warning. |
Previous conversation could not be resumed; started a new one. after upgrading from 0.2.2 or earlier |
Conversations from before 0.2.3 (the Codex releases) were stored elsewhere and do not carry over. |
A tool result starting with ERROR: inside the conversation |
The agent hit a marimo or allowlist problem; the text contains the fix (for example the URL to open). |
Marimo at <url> rejected the request (HTTP 401). (or 403) and the hint mentions .hailer/kernel.json and HAILER_MARIMO_TOKEN |
The server was started with a token. If Hailer started it, run Hailer in the same workspace, so it reads the token from .hailer/kernel.json. For a server you started yourself, export its token as HAILER_MARIMO_TOKEN (kept in memory only), or restart it with --no-token. |
Known limitations
marimo._code_modeis a private API; Hailer pins marimo 0.24.2 and may need changes for other versions.- The notebook must be open in a browser; a headless server without a tab has nothing to execute against.
Switching notebooks opens a new tab; the old one stays open until you close it or
/notebook closeit. When the agent switches notebooks and the browser is slow to load, the CLI may open the same notebook a second time after the turn (it opens the URL once more when it still sees no kernel session); the extra tab is harmless, close it. - If you press Ctrl+C while the agent is in the middle of
notebook_create/notebook_open, that tool call may still finish and switch the active notebook a moment later. Hailer re-reads the state after every turn and before/notebookand/status, so the next command shows the right notebook; a/notebook newtyped in that instant can still be overtaken by the late switch. uvx hailer notebookstarts and stops marimo for you; plainuvx hailerexpects a running server and tells you how to start one.- Every request carries about 10 KB of instructions and 5 KB of tool definitions (measured with an empty project context) plus the conversation; an endpoint with a strict request-size limit needs room for that.
- The endpoint must support function calling in the standard OpenAI shape. An endpoint that streams tool
calls in a non-standard way may lose their arguments;
stream = falseon the provider avoids that. - After Ctrl+C during a long
marimo_execute, the turn ends at once but the code keeps running in the kernel until it finishes, or until you interrupt or restart the kernel from the notebook. - The docker kernel needs Docker, which is not always an option: Docker Desktop is free for personal use
and small businesses, but larger organisations need a paid subscription (check Docker's current terms),
and managed machines often block Docker Desktop, WSL2 or Hyper-V. That is why
localstays the default and fully supported. - Reading large files through a Windows bind mount is slower than reading them from a local folder; how much slower for large Parquet files has not been measured yet.
- The
linux/arm64kernel image (Apple Silicon, ARM Linux) is built by the release workflow but untested until the first release that publishes it. Podman is not supported. - The docker kernel has only the packages in the image (marimo, Polars, DuckDB, altair, plotly); anything
else needs an image of your own, built
FROMHailer's (so it keeps the version label) and named in[kernel].image. There is one kernel per workspace, and changing[kernel]settings while one is kept running needsuvx hailer kernel stopfirst.
Release files for hailer 0.2.6
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| hailer-0.2.6.tar.gz | 553.8 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| hailer-0.2.6-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 729.1 kB
Release files / hailer-0.2.6.tar.gz
| Download URL | hailer-0.2.6.tar.gz |
|---|---|
| Size | 553.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
1a009e7187bbf90ece9c0ae4261946519524e2df679842c03053ae233de19eef
|
|
BLAKE2b-256 checksum How to use checksums |
5c5432217d032c1cc7cf4c7966a52046d43b61cd757476d836adeab52f50ff3f
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 19, 2026.
Transparency logRelease files / hailer-0.2.6-py3-none-any.whl
| Download URL | hailer-0.2.6-py3-none-any.whl |
|---|---|
| Size | 175.4 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
9ac781c5be53feef891461ac510a4dfdc79007239af60f934bf9b36d539a9fb8
|
|
BLAKE2b-256 checksum How to use checksums |
66fb6dd2f90d4e42470f2a1a9f5b422261010c0e66fcbd2de09cba9289feff6c
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 19, 2026.
Transparency log