NewYour coding agent can read the release notes before it upgrades.Set up the MCP server →
PyPI
The web stage for your LangGraph agent — AI-powered workspace with streaming, file browser, and canvas
Last release today
19 Oct 2026
Release timing varies
gaps range from 8 days to 2 months
Nearly every release is documented
notes for 55 of the last 60 stable releases
Nothing withdrawn
no release was ever pulled
4 months old
71 releases · first in 2026
One column per month.
allowed_hosts accepts *.example.org to allow every subdomain of a name, as langstage-codraft 0.5.4 does, so one langstage.toml behaves the same in bot
allowed_hosts accepts *.example.org to allow every subdomain of a name, as
langstage-codraft 0.5.4 does, so one langstage.toml behaves the same in both stages.
The bare example.org still needs its own entry.The local server refuses requests addressed to a name it doesn't answer to (gh #208). With DNS rebinding, a page at http://evil.example:8050 re-points
http://evil.example:8050 re-points its name at 127.0.0.1.
The browser then treats your local server as that site's own origin, so the #206
cross-site guard can't tell, and the page can read your files and drive the agent. On a
server bound to a loopback address (the default localhost), every request, GET
included, whose Host header isn't localhost, 127.0.0.1, [::1], a *.localhost
name, the bind host or an entry in the new allowed_hosts setting now gets 403. The
403 names the host and the setting.allowed_hosts ([server] allowed_hosts in langstage.toml, or
LANGSTAGE_ALLOWED_HOSTS, comma-separated): extra names the server answers to. * turns
the check off. It's shown in config / --show-config.allowed_hosts. Caddy and Traefik pass the browser's Host through by default, so put
your public name in allowed_hosts (for example
LANGSTAGE_ALLOWED_HOSTS=agent.example.org). nginx's plain proxy_pass sends
127.0.0.1:<port> as the Host and keeps working. Servers bound to a non-loopback
address (--host 0.0.0.0) are unchanged.Agent HTML and Plotly run in an isolated frame (gh #212). The Canvas and the chat's inline renderer framed agent-produced HTML and Plotly with sandbox
sandbox="allow-scripts allow-same-origin", which gave the frame the app's own origin.
A script in that content (from a fetched page, an email, a tool result) could read,
overwrite and delete workspace files through the REST API, past the #206 cross-site
guard, and reach the parent page. The frames now use allow-scripts only, like the
Files viewer: the content runs in an opaque origin, while Plotly and auto-resize still
work./api/stream never ended, so the
server hung on "Waiting for connections to close" until a second Ctrl+C (with
tracebacks, skipping the scheduler and task-runner shutdown) or SIGKILL. Open streams
now end as soon as shutdown starts, and the lifespan shutdown runs. In a notebook,
BackgroundServer.stop() stops the server instead of returning after 5 s with it
still running. A 5 s graceful-shutdown limit bounds any other long-lived request.A relative `[ui] custom_css` resolves against its `langstage.toml` (gh #209). It used to resolve against the working directory, so running from a subd
[ui] custom_css resolves against its langstage.toml (gh #209). It
used to resolve against the working directory, so running from a subdirectory of the
project (where the toml is found by walking up) silently dropped the theme:
/api/custom-css returned 204 and only a log line said why. It now resolves like
[workspace] root and [agent] spec, and langstage config shows the absolute path.
A leading ~ is expanded in every layer (LANGSTAGE_CUSTOM_CSS=~/theme.css, --custom-css);
env and flag values stay relative to the working directory, and a blank
custom_css = "" still means no theme.A cross-site form can no longer change the workspace (gh #206). CORS only stops a foreign page reading a response. A plain HTML POST, with its input i
<form> POST, with its input in the
query string or a urlencoded / multipart / text/plain body, is a CORS "simple
request" that the browser sends without a preflight, so any website could delete,
overwrite or create workspace files (/api/files/delete|upload|mkdir) and start a
schedule (/api/cron/{id}/run) on a default local server. The server now refuses,
with 403 and a JSON detail, every POST/PUT/PATCH/DELETE whose Origin is
neither the server's own origin nor allowed by the CORS setting (loopback origins by
default, or exactly LANGSTAGE_CORS_ORIGINS), including Origin: null, and any
without Origin that the browser marks Sec-Fetch-Site: cross-site or same-site.
Sec-Fetch-Site: same-origin is accepted as the browser's own attestation. Requests
with neither header (curl, scripts, the CLI) are unchanged, and so are GET routes
and the SSE stream. One middleware covers every route, and it runs with
--auth-password set too.chat says how to answer a paused turn (gh #205). After the "Allowed decisions"
line, human mode prints the commands that run the same agent where the decision can
be made, langstage run -a <spec> (the web app's approval dialog) and
langstage-cli -a <spec> (the terminal's approval menu, with langstage-cli
installed), or --demo for the demo agent. It goes to stderr like the rest of the
pause; --json is unchanged.`chat` shows what a paused turn is waiting for (gh #203). When a turn paused on a human-in-the-loop interrupt, chat exited 2 but dropped the interrupt
chat shows what a paused turn is waiting for (gh #203). When a turn paused on a
human-in-the-loop interrupt, chat exited 2 but dropped the interrupt and reported
the pause as an error: --json gave {"error": "turn did not complete: interrupted"}
with no outcome or interrupt, and human mode printed only Error: ..., so the
agent's question never showed. --json now always has the same keys, content,
tool_calls, outcome (complete / interrupted / error), interrupt and
error, with outcome and interrupt as POST /api/chat/complete reports them and
error null for a pause. Human mode prints each request (its name, question or
arguments) and the decisions it allows to stderr, under "Paused: the agent is waiting
for a decision.", and keeps stdout for the reply. Exit codes are unchanged.The Canvas keeps non-ASCII text on Windows. Every canvas file (canvas.md, chart JSON, tables) was read and written with the locale encoding, which is
canvas.md, chart
JSON, tables) was read and written with the locale encoding, which is cp1252 on
Windows. Plotly writes UTF-8, so a chart titled "Q2→Q3" showed as "Q2→Q3", and a
markdown note with "→" or an emoji failed to save. The canvas now reads and writes
UTF-8 everywhere, and so does the custom CSS file.The Canvas refreshes on Windows. The file watcher built workspace paths with str(), which uses backslashes on Windows, so a canvas write arrived as /.
str(), which uses backslashes on Windows, so a canvas write arrived as
/.canvas\canvas.md. The UI refreshes the Canvas on a change under /.canvas/, so
on Windows it stayed empty until a reload. File-tree paths had the same backslashes.
Both now use forward slashes on every OS.The Canvas works. CanvasMiddleware offered its five tools to the model but never registered them with the agent, so every call failed with "add_to_can
CanvasMiddleware offered its five tools to the model but never
registered them with the agent, so every call failed with "add_to_canvas is not a
valid tool" and the Canvas stayed empty. That held for the default agent and for an
agent built the way this README shows. The middleware now registers the tools
through its tools attribute, the way LangChain's create_agent (and deepagents)
expects.GraphRecursionError after 25 steps.A paused task on the Board can be answered (gh #198). The review gate in a task's detail view offered only Approve and Reject, whatever the interrupt
ask_user, or a
plain interrupt("...")) couldn't be answered from the Board. The gate now shows the
request (its name, description and arguments, or the question's text) with the same
controls as the chat's approval dialog: Approve, Reject, Edit and Respond, each shown
when the interrupt allows it. An interrupt that names no verbs still gets Approve and
Reject. A paused task no longer says "Waiting for the agent…" above the gate.The approval dialog can answer a question (gh #195). An interrupt that allows respond, such as the demo agent's ask me ("What should I call you?"), sh
respond, such as the demo agent's ask me ("What should I call you?"), showed only an
Approve button, so the agent's question could not be answered. The dialog now adds a text
box and a Respond button whenever respond is allowed; it sends
{"type": "respond", "message": ...} (one decision per action request, like Approve) and
submits on Enter. The action's label also fell back to blank for requests keyed action
(ask_user) or name (LangChain's HITL middleware) instead of tool; it now shows
whichever is present, and a bare-string interrupt("...") request shows its text./api/stream sends
{"type": "reasoning", ...} frames, but the frontend had no handler, so a reasoning
model's chain-of-thought was silently dropped. It now accumulates into a collapsed
"Thinking" block above that turn's answer. The Board's task detail renders a task's
reasoning the same way.An explicit title or agent name equal to the default is kept (gh #192). With a named agent, --title "LangStage" or LANGSTAGE_AGENT_NAME=Agent (and the
--title "LangStage" or LANGSTAGE_AGENT_NAME=Agent (and the same value from
langstage.toml or the Python API) was replaced by the agent's name, because "not set"
was decided by comparing the value to the default string. It is now decided by where the
value came from, so only an unset title or agent name takes the agent's name./api/custom-css answers 204 No Content when no custom CSS is configured (gh #193).
It returned 404, and the frontend fetches it on every page load, so the default setup
logged a console error on every page. The frontend already treats an empty response as
"nothing to apply", so the page itself is unchanged.`chat --json` prints JSON when the agent fails to load (gh #189). A load failure (a bad --agent spec, or the default agent without the deepagents extr
chat --json prints JSON when the agent fails to load (gh #189). A load failure (a
bad --agent spec, or the default agent without the deepagents extra) printed only a
plain Error: ... line to stderr, so a langstage chat --json | jq pipeline failed to
parse instead of reading the error. It now prints the same
{"content": "", "tool_calls": [], "error": ...} object as a failed turn (with the error
formatted as check --json formats it), and still exits 1.The built-in default agent populates the Plan tab again (gh #149, #150, thanks @kkkhs). deepagents 0.7 dropped write_todos from its defaults, so the b
write_todos from its defaults, so the bundled agent
stopped emitting todos. It now adds TodoListMiddleware when the built graph lacks
write_todos, without duplicating it on deepagents versions that still include it. Tool
introspection (used by check) also reads middleware-provided tools.check and chat use the default agent run serves when nothing is configured
(gh #187). With no --agent, no LANGSTAGE_AGENT_SPEC and no [agent] spec, run serves
the built-in default agent, but check and chat failed with "No agent to use".
check now preflights that agent (--json reports "default_agent": true and
"spec": null), and chat runs its turn. Without the deepagents extra, both show the
same install hint as run.`langstage check` reports the checkpointer `run` actually uses (gh #183). For a graph compiled without one it said LangStage would attach an in-memory
langstage check reports the checkpointer run actually uses (gh #183). For a graph
compiled without one it said LangStage would attach an in-memory checkpointer, but the
server swaps that for a durable SQLite one at <workspace>/.langstage/checkpoints.db
(the persisted schedules and task board rely on it). check now reports that SQLite
saver, or the in-memory fallback (with a warning) when the SQLite saver can't be
imported. A graph's own checkpointer is named by type and used as is; an in-memory one
the graph brings (InMemorySaver / MemorySaver) now warns that it isn't durable. The
--json shape is unchanged (checks.checkpointer.ok / .detail).Exit codes follow the LangStage family scheme (langstage-core ADR 0007): 0 success, 1 failure, 2 paused on a human-in-the-loop interrupt, 64 usage err
0 success, 1 failure, 2 paused on a human-in-the-loop interrupt, 64 usage error.
Scripts that matched the old codes need updating:
--port / --theme,
--demo with --agent, a missing chat prompt) exit 64 (were click's 2).check / chat with no agent configured exit 1 (were 2).chat that ends on a human-in-the-loop interrupt exits 2 (was 1).run on a busy port prints cannot serve at <url>: ... and exits 1 before the
banner (was the banner, then uvicorn's exit 3). Any other startup failure from
uvicorn also exits 1.langstage --help lists the exit codes; README has an "Exit codes" section.The DEEPAGENT_ and COWORK_ names still work with a deprecation notice, and an invalid value falls back to 512 MB with a note.
/api/stream sessions are reclaimed (gh #164). Each stream connect with a
new or absent session_id created a session that stayed in memory after the client
left. A session with no open stream and no running turn is now dropped 60 seconds
after its stream closes, along with its notebook state. The conversation is still in
the checkpointer, so reconnecting with the same session_id continues it./api/config workspace_name is set for a relative workspace (gh #145). The
default workspace . has no basename, so langstage run --demo sent an empty name.
The app now keeps the resolved absolute workspace path.langstage check no longer reports agent name: LangGraph (gh #153). run
ignores a compiled graph's default name and shows its own defaults. check now does
the same: it prints a [warn] line and --json reports agent_name: null.LANGSTAGE_CELL_MEMORY_LIMIT_MB sets the execute_python memory limit (gh #139).
Only the old COWORK_CELL_MEMORY_LIMIT_MB worked, and a malformed value raised on
import. The DEEPAGENT_ and COWORK_ names still work with a deprecation notice, and
an invalid value falls back to 512 MB with a note.POST /api/cron rejects fields it would ignore (gh #161). enabled: false was
dropped, so the schedule was created active and fired anyway. The body now takes only
name, cron, and prompt. enabled gets a 422 saying pausing isn't supported
(delete and recreate instead), and any other unknown field gets a 422 too.`tool_start` and `content` carry the right `node` on multi-node graphs (gh #173). With a tool call and the reply in different nodes, and complete mess
Adopts langstage-core 1.0.37.
tool_start and content carry the right node on multi-node graphs (gh #173).
With a tool call and the reply in different nodes, and complete messages instead of
streamed tokens, /api/stream labeled tool_start with the run's last node and the
reply with the node that made the tool call. The label came from the checkpoint,
and the web app's AsyncSqliteSaver could write it after the snapshot was read.
Core 1.0.37 takes each message's node from the step's own update instead. The web
app had no workaround to remove. tests/test_node_attribution_173.py runs the
issue's three-node graph under the server's SQLite saver and under a saver whose
writes land late.langstage-core[agui] floor raised to >=1.0.37.Local correctness fixes for the files API, CORS under auth, the canvas list and the notebook path. No dependency change.
Local correctness fixes for the files API, CORS under auth, the canvas list and the notebook path. No dependency change.
GET /api/files/read returns the file as it is on disk (gh #144). It used
read_text(errors="replace"), so a CRLF file lost every , a binary came back as
lossily decoded language: "text", and size was the decoded character count. The
bytes are now decoded without newline translation, and size is the byte size that
tree, upload and download report. A file that isn't UTF-8 text (or contains a
NUL byte) gets 415 pointing at /preview or /download, instead of mangled text.str.split, so an RFC-4180 cell like "Smith, John" produced too many columns
and the row was silently dropped. The preview now uses the csv module: quotes are
stripped, a quoted newline stays in its cell, and a ragged row is padded or trimmed
to the header instead of dropped.download_url is URL-encoded (gh #163). A PDF or binary named
Q3 report.pdf got a link with a raw space, and &, +, # or % in a name sent
the download to a different path.DELETE resolved
the path first, so removing link -> data/ deleted data/. A symlink or Windows
junction is now unlinked itself. #148's containment rule applies to where the link
lives, so a path reached through a link that points outside the workspace is still
rejected. A link that points outside, or a dangling link, can now be removed.--auth-password set (gh #155). Basic auth was the
outermost middleware, so it answered every preflight OPTIONS (which browsers send
without credentials) with 401, and the Vite dev server or an origin opted in via
LANGSTAGE_CORS_ORIGINS could not reach an authenticated server. CORS is now outside
auth and answers preflights itself. The request that follows is still authenticated,
a bare OPTIONS still needs credentials, and the loopback-only default is unchanged.GET /api/canvas/items no longer 500s on a real canvas (gh #158).
CanvasItemResponse required title and typed data as an object, but sections
and markdown or HTML items have no title and a string data. The model now matches
what the canvas tools write: title, created_at, level, file, source_cell
and execution_count are optional, and data is any JSON value. The OpenAPI schema
follows.app.run() in a notebook no longer changes the kernel's working directory
(gh #156). The chdir into the workspace (ADR 0006) is for a process the server owns.
In a notebook it moved the kernel's cwd, so later cells' relative paths resolved in
the workspace. The script and CLI path still enter the workspace.Adopts langstage-core 1.0.36, which fixes several of these at the root, and moves the CLI onto core's config reporting and console helpers. Requires `
Adopts langstage-core 1.0.36, which fixes several of these at the root, and moves the
CLI onto core's config reporting and console helpers. Requires langstage-core>=1.0.36.
config / --show-config no longer say "no langstage.toml found" when the file is
there but malformed (gh #170). The footer now names the file as MALFORMED, with the
parse error. config --json is now core's config_dict(): each field also carries its
env var / TOML key, toml gains found, paths, malformed and malformed_files, and
a top-level issues list is added. The old toml_read_from and unknown_toml_keys
keys are kept.config --strict fails on every degraded value, not only unknown keys (gh #138). It
exits 1 on a malformed langstage.toml, a malformed value (LANGSTAGE_PORT=notanum,
LANGSTAGE_SHOW_FILES=flase), an invalid value (port 70000, an unknown theme from env
or TOML) or an unknown key. It prints one stderr line per issue, and --json lists them
under issues.check --json / chat --json stay pure JSON when the agent prints at import time
(gh #140). The agent is loaded with core's stdout_to_stderr=True, so a library's
load banner goes to stderr and the README's | jq -e ... gate parses.config / --show-config no longer crash on a cp1252 console (gh #146) when a
value such as the welcome message contains an emoji. All CLI output now goes through
langstage_core.console (safe_print, or console_safe for the colored check
lines), which replaces the local _echo_streamsafe.langstage run --debug puts the agent's crash traceback on the SSE error frame
(gh #134), as LANGSTAGE_DEBUG=1 already did. A resolved debug=True from the flag or
the Python API is published as LANGSTAGE_DEBUG, which core reads when it reports an
error.extraction after the call's tool_end, but the UI only attached it to a call still
marked running. The extraction was dropped, or landed on a parallel call to the same
tool. It is now matched by id: the frame's own, or the tool_end right before it. The
task-detail view had the same bug and gets the same fix.BackgroundServer.stop() (or served again in the same process) keeps working.file.py:attr agent can import its sibling modules (gh #167).typing.TypedDict state works on every turn on Python 3.11 (gh #166).tool_end carries a real duration_ms, so the tool-call card shows the duration the
README advertises (gh #160).DEEPAGENTS_CONFIG_HOME prints the one-time legacy notice (gh #136).load_agent_spec now rejects a spec whose attribute is a str, with a clear error.
check reports it as a load failure rather than "not runnable".pkg.mod:attr spec from langstage.toml resolves from that file's directory.Security and data-loss fixes. No dependency change.
Security and data-loss fixes. No dependency change.
GET /api/files/tree no longer follows symlinks out of the workspace (gh #148).
The entry path was checked (tree?path=leak → 400), but the recursive walk built
child paths lexically and descended through symlinks. A workspace symlink pointing
outside it (for example one the agent's own bash tool planted) made tree?depth=N
list the target's names, structure and file sizes. A file symlink leaked its target's
size even at the default depth=1. The walk now applies the same resolved-path
containment rule as _resolve_path to every entry. An entry whose real location is
outside the workspace (symlink or Windows junction) is left out and never
descended into. Symlinks that stay inside the workspace are still listed. The sibling
routes (read, preview, download, upload, mkdir, delete) all go
through _resolve_path, which resolves symlinks first. They already refused
escaping links, and a new test pins that for each one.<workspace>/.langstage/tasks.db. Schedules are now stored in a
cron_jobs table in that same database. They are written through on create,
delete and each fire (run stats included, so overlap protection still knows the
previous run's task after a restart) and reloaded on startup. next_run is
recomputed from the cron expression. Fires missed while the server was down are
not replayed. A stored row whose cron no longer validates is skipped with a
warning. The /api/cron request/response shapes and the OpenAPI schema are
unchanged. The Schedules tab hint and the schedule_run tool description no
longer say "in-memory".create_cell, execute_cell,
get_variables, reset_notebook and the rest all shared one process-global
notebook. A background task or scheduled run could therefore read the chat's
variables or reset them away. Each run now gets its own cells and namespace,
keyed by the run's thread_id: the chat session id, or task-<id> for board and
scheduled runs, the same id the checkpointer uses. Callers with no session keep
the shared default notebook. A session's notebook is freed when the session is
deleted, and idle ones are evicted least-recently-used beyond 64. When IPython is
installed, its singleton shell is bound to the calling notebook's namespace per
run, and those runs are serialized.Docs-only. No code or dependency change.
Docs-only. No code or dependency change.
LANGSTAGE_CONFIG_HOME (gh #128). The section
described a single, cwd-local langstage.toml, but langstage-core's resolver
(HostConfig.resolve()) actually reads two deep-merged TOML layers: the global
~/.langstage/config.toml (directory overridable via LANGSTAGE_CONFIG_HOME; legacy
~/.deepagents/config.toml / DEEPAGENTS_CONFIG_HOME still honored) and the project
langstage.toml — the latter discovered by walking up the directory tree from the
working directory (not from --workspace), and winning key-by-key over the global file.
The corrected chain is now stated as Python args > CLI args > environment variables >
project langstage.toml (nearest at or above the working directory) > global
~/.langstage/config.toml > defaults, with a note that a parent-directory or global
file can silently set values like auth.password / server.host, so langstage --show-config is the way to see the resolved source file. This closes the "Related doc
gap" flagged when #119 shipped. tests/test_readme_config_priority.py is updated to pin
the new chain and the global-layer docs.Requires langstage-core >= 1.0.33 (bumped from >=1.0.32): #123 is fixed at the shared core layer and this release wires it through, adds the --port CL
Requires langstage-core >= 1.0.33 (bumped from >=1.0.32): #123 is fixed at the
shared core layer and this release wires it through, adds the --port CLI guard, and
ships two langstage-local fixes (#124, #125).
70000, 99999) is no longer accepted and silently
mis-bound (gh #123). A type-valid but out-of-range integer port sailed through the
resolver, was advertised by --show-config and the startup banner, and then uvicorn
masked it to 16 bits at bind time (70000 & 0xFFFF == 4464) — the server listened on a
different port than everything advertised, with no error. Now: the ambient paths
(LANGSTAGE_PORT env, server.port in langstage.toml, a Python port= override)
degrade an out-of-range value to the default 8050 + a one-line stderr note: and
attribute it to [default] (via langstage-core 1.0.33's resolve() port-range
validator — the same treatment a non-int value already got), and the interactive
--port flag hard-errors cleanly at parse time (Invalid value for '--port': 70000 is not in the range 1<=x<=65535) via click.IntRange, mirroring how --theme
hard-rejects. Either way the silent misbind is gone.GET /api/files/download?path=<a dir> now returns a clean 400 "Path is a directory"
instead of a misleading 404 "File not found" (gh #124). Downloading a directory
path collapsed "exists but is a directory" into the same 404 as "doesn't exist",
telling a client an existing path was gone. The route now distinguishes the two —
400 for a directory (matching read/preview, and the #117 tree-on-a-file fix),
404 only when the path truly doesn't exist. download was the last /api/files/*
route to misreport a node type.langstage config --strict — a CI gate on a typo'd/unknown langstage.toml key (gh
#125). config surfaces unknown keys (via core's unknown_toml_keys()) but always
exited 0, so a deploy/CI step couldn't fail the build on a config typo without
hand-parsing the JSON. --strict exits 1 when the config isn't clean (any unknown
key), and 0 when clean, so langstage config --strict is a one-line CI gate. The
default (no --strict) still exits 0, keeping the "degrade, don't crash on ambient
config" contract. Composes with --json (same output on stdout, the strict error
summary on stderr, same exit-code contract).Requires langstage-core >= 1.0.32 (bumped from >=1.0.9): #119 and #120 are fixed at the shared core layer and this release wires the fixes through lan
Requires langstage-core >= 1.0.32 (bumped from >=1.0.9): #119 and #120 are fixed at the shared core layer and this release wires the fixes through langstage's config surface.
GET /api/files/tree?path=<a file> now returns a clean 400 instead of HTTP 500
(gh #117). Passing a file path where a directory is expected raised an uncaught
NotADirectoryError out of the /tree route — the only /api/files/* route that
leaked a 500 for a wrong node type (the boundary always held; it was an
error-handling gap). The route now catches NotADirectoryError and returns
400 {"detail": "Not a directory: …"}, the mirror image of read/preview's
existing IsADirectoryError → 400 guard for the inverse case.langstage config / --show-config now attributes each TOML value to the file it
actually came from (gh #119). When both a global ~/.langstage/config.toml and a
project langstage.toml were present, every TOML-sourced field was labeled
[toml (langstage.toml)] — even a value that existed only in the global file —
because the deep-merged config dict had lost per-key provenance. Values always
resolved correctly (global < project); only the source column was wrong, silently
defeating the deploy-time "where did this value come from?" assertion the column
exists for. Fixed in langstage-core 1.0.32's resolve() (each key is attributed to
the highest-precedence file that actually defines it); langstage inherits it —
subtitle set only in the global file now reads [toml (config.toml)].langstage config / --show-config now flags unknown / typo'd / misplaced keys in
langstage.toml (gh #120). A key that maps to no known field — a typo (titel),
a key in the wrong section (prot under [server]), or an entirely unknown
table — was silently dropped, so the "edit it, then langstage config to verify"
workflow couldn't catch the single most common hand-edit mistake (config just
reported the field as [default]). config and --show-config now print
unknown TOML keys (ignored - a typo or wrong table?): … (via langstage-core
1.0.32's HostConfig.unknown_toml_keys(), surfaced by describe()), and
config --json reports them under a new unknown_toml_keys field so a deploy step
can assert on them machine-side. Keys under the [configurable] passthrough table
are never flagged. Advisory only (exit 0), matching the legacy-env-var notice.CORS no longer reflects every origin with credentials on the default local server (gh #113). The server attached CORSMiddleware with allow_origins=["*
CORSMiddleware with allow_origins=["*"] and
allow_credentials=True; Starlette turns that combination into "reflect the caller's
Origin back + Access-Control-Allow-Credentials: true", so on the default
langstage run (localhost, no password) any web page the user visited could make
credentialed cross-origin requests to their local server and read the responses —
read/write the workspace file browser, read chat/config, delegate agent runs, create
cron jobs. CORS is now loopback-only by default: credentialed access is granted
only to http(s)://localhost, 127.0.0.1, or [::1] (any port) via
allow_origin_regex, which is everything the same-origin SPA and a local Vite dev
server need — a drive-by origin (https://evil.example, the null origin of a
sandboxed iframe/file://) is never reflected, so the browser hands it no grant to
read the response. A user who genuinely needs to allow specific cross-origin sites can
opt in with LANGSTAGE_CORS_ORIGINS (comma-separated); a literal * there is honored
only with allow_credentials=False (the browser forbids * + credentials anyway).LANGSTAGE_SHOW_FILES / LANGSTAGE_SHOW_CANVAS values now degrade with a
note instead of being silently swallowed to auto and still credited to the env var in
--show-config (gh #112). theme got this treatment in #104, but the two boolean UI
fields went through a lenient parser that mapped any unrecognized string to None
(auto) with no signal — so LANGSTAGE_SHOW_FILES=flase (a typo of false, meant to
hide the Files tab) silently showed it, and --show-config misleadingly
reported [env:LANGSTAGE_SHOW_FILES] as honored. These two fields now resolve through a
strict caster: a non-empty unrecognized value is rejected by the shared resolver's
malformed-value guard, which emits the one-line note: ignoring malformed …; using default None instead. and repoints the recorded source to [default] — matching the
#104 / theme behavior. Valid values (on/off/1/0/…) resolve unchanged..log / .ini / .cfg / .conf
as text instead of binary (download-only) (gh #114). The preview path
(GET /api/files/preview, the only files endpoint the SPA calls to view a file) decided
"is this text?" from file_manager.LANGUAGE_MAP, which omitted those extensions — while
the read path's file_utils.is_text_file / TEXT_EXTENSIONS already treated them as
text, so the two detectors contradicted each other (notably for server.log, which
--demo/run itself writes). Preview's text set is now the union of LANGUAGE_MAP
and TEXT_EXTENSIONS, sharing one source of truth so the two can't drift apart again;
the binary guard is unchanged for genuinely non-text files.langstage chat no longer crashes with UnicodeEncodeError when the agent reply
contains a non-cp1252 character (emoji/CJK/arrows) on a non-UTF-8 console (gh #115).
The reply was printed via a bare click.echo, which on the default Windows cp1252
console raised as soon as the reply held an emoji/CJK/arrow — losing the answer and,
worse, flipping the exit code to a false 1 even though the agent succeeded, breaking
chat's documented readiness-gate contract (a CI/cron gate saw a failure purely because
the correct answer had an emoji in it). The reply is now written with an error-tolerant
policy (errors="backslashreplace"), so an un-encodable char degrades to an ASCII
\uXXXX escape (the same fidelity --json already got from ensure_ascii), the answer
is shown, and the exit code reflects the agent's real outcome.langstage chat now chdirs into the resolved workspace before the turn, so the agent's process os.getcwd() and its relative file ops actually land in t
langstage chat now chdirs into the resolved workspace before the turn, so the
agent's process os.getcwd() and its relative file ops actually land in the workspace
— matching a browser turn instead of only naming the workspace in the injected
context (gh #110). This is the residual gap left after #106. That fix made chat
inject the same [Working directory: <workspace>] context line the web chat does, but
chat never entered the workspace the way the web server does: run() calls
CoworkApp._enter_workspace() (which does os.chdir(workspace_root()), per ADR 0006),
while chat constructed CoworkApp(...) and called run_turn_sync(app.agent, …)
directly — never run(), never _enter_workspace(). So the injected line said the
working directory was the workspace while the process cwd stayed the launch dir, and
a bring-your-own agent's raw relative write (Path("out.txt").write_text(...)) landed
outside the workspace the file browser shows — inconsistently with what the same
turn does in the browser.
chat now enters the resolved workspace (via app._enter_workspace()) after
CoworkApp has resolved and apply_workspace'd it and before run_turn_sync, exactly
mirroring run(). The chdir is unconditional, so --no-context follows the
workspace too — cwd tracks the resolved workspace whether or not the context line is
injected.run() path and __init__'s deliberate no-cwd-side-effect contract are
unchanged (embedding CoworkApp still never moves the process cwd).`POST /api/cron` (and the `schedule_run` agent tool) now reject any non-5-field cron expression with the same clean 400 the 4-field case already got —
POST /api/cron (and the schedule_run agent tool) now reject any non-5-field
cron expression with the same clean 400 the 4-field case already got — so a 6- or
7-field cron pasted from another scheduler can no longer be silently accepted and
fired at an unintended time (gh #108). Validation delegated entirely to
croniter.is_valid, which also accepts croniter's 6-field (seconds) and
7-field (seconds + year) extensions. But both the endpoint's own 400 message and
the README document standard 5-field UTC cron only ("Cron is interpreted in UTC.
0 9 * * * fires at 09:00 UTC"). The result was a silent-misinterpretation bug: a
4-field cron was correctly rejected with "Expected 5 fields 'min hour day month
weekday'", but a 6-field Quartz/Spring/k8s-style cron such as 30 0 9 * * * was
accepted (201) and read by croniter as min=30 hour=0 day=9 month=* weekday=* second=* — a next_run on the 9th at 00:30 UTC, not the ~09:00 the user meant —
with no error and no warning. That is the exact class of misinterpretation the
strict-5-field message was written to prevent.
validate_cron now asserts the field count before croniter:
len(expr.split()) != 5 raises the existing "Expected 5 fields …" ValueError,
so anything but a standard 5-field expression is rejected up front. str.split()
(no argument) splits on runs of whitespace, so "0 9 * * *" (double space) is
still 5 fields; steps and ranges like */15 * * * * and 0 9 * * 1-5 still pass.validate_cron, which both the HTTP
create path (POST /api/cron → CronScheduler.add_job) and the agent-facing
schedule_run tool call — so both surfaces enforce the identical 5-field contract,
and the endpoint's 400 message is finally true. The 4-field rejection is unchanged.`langstage chat` now injects the same per-message context the web chat does, so a headless turn is genuinely identical to a browser turn — matching wh
langstage chat now injects the same per-message context the web chat does, so
a headless turn is genuinely identical to a browser turn — matching what it (and
oneturn.py) already advertised (gh #106). The two "buffered one-turn" siblings
that share langstage/oneturn.py:complete_turn — the CLI langstage chat and the
HTTP POST /api/chat/complete — fed the agent different inputs for the same
prompt. Both web paths (POST /api/chat SSE and POST /api/chat/complete)
prepend a per-message context block — [Current time: …] + [Working directory: …]
— via routes_chat.context_parts(); langstage chat deliberately did not. That
contradicted the very claim both docstrings made — the chat docstring's "the reply
is exactly what a browser would render" and oneturn.py's "identical to what a
browser would have rendered" — and it silently defeated the feature's whole purpose:
chat / check --live are sold as a low-ceremony readiness gate that mirrors a
production turn, but for any time-aware or workspace-aware agent — including the
built-in default agent, whose entire system prompt is about operating in the
workspace — the gate validated a materially different input than what ships. The
keyless echo demo made the divergence visible: langstage chat --demo "ping" replied
You said: ping, while POST /api/chat/complete {"content":"ping"} replied
You said: [Current time: …]\n[Working directory: …]\n\nping.
langstage chat now calls the same routes_chat.context_parts() the web paths
use and forwards it through the shared complete_turn, so both siblings feed the
agent the same input and produce the same reply. CoworkApp has already resolved
and apply_workspace'd the workspace by the time the context is built, so the
reported [Working directory: …] is the real resolved workspace root (the browser's
default folder), not the launch cwd. The CLI has no file browser, so no cwd
subfolder is applied.--no-context flag opts back out — the terse prompt in -> answer out
echo, for clean scriptable output — at the explicit cost of no longer being
browser-identical. This keeps the #101 "clean --demo echo" use case one flag away
without letting the default silently misrepresent a browser turn.chat command docstring and oneturn.py's
module docstring are corrected to describe the shipped behavior precisely: identity
of output follows from feeding the same input, which both callers now do by
default; --no-context is the documented exception. The historical 0.13.27
changelog entry is left as-is (it records what shipped then); this release makes its
"exactly what a browser would render" description true by default.The `theme` enum is now enforced on the env / TOML / Python-API paths, not just the `--theme` CLI flag (gh #104). theme is documented as a three-value
theme enum is now enforced on the env / TOML / Python-API paths, not
just the --theme CLI flag (gh #104). theme is documented as a three-value
enum — --theme [light|dark|auto] in the CLI --help, theme = "auto" in the
init template, # "light" | "dark" | "auto" in config.py — but that enum was
enforced on only the --theme flag (a click Choice). LANGSTAGE_THEME,
ui.theme in langstage.toml, and the Python AppConfig(theme=…) constructor
all resolved any string silently: an invalid value flowed through AppConfig,
was reported by --show-config / config as a legitimately-resolved setting,
and shipped to the client via GET /api/config, where the bundled UI (which
only matches dark/light/auto) silently ignored it — the same
"advertised != honored" gap the Configuration surface exists to rule out, with
--show-config confirming a value the CLI would have rejected.
AppConfig(theme=…) constructor)
now degrade an invalid value to the default "auto" and print a one-line
stderr note: naming the bad value and the accepted set — note: ignoring invalid theme 'purple' (expected one of: light, dark, auto); using default 'auto' instead. This follows the "degrade, don't crash on ambient config"
contract langstage-core adopted for malformed numeric config (>= 1.0.23):
crashing an entrypoint on an env var or a config file is worse than degrading,
so an invalid theme in langstage.toml must not brick the server.
--show-config then reports theme = auto [default] — never crediting the
rejected env var / TOML key as the source — and the client can no longer
receive an un-honorable theme.--theme flag keeps its immediate hard Choice rejection
(Error: Invalid value for '--theme': 'purple' is not one of 'light', 'dark', 'auto'., exit 2): rejecting an interactive argument on the spot is fine;
degrading ambient config is the safer default.Choice(["light", "dark", "auto"]), so the accepted set is identical across
all four sources. It is enforced in AppConfig by a post-resolution
normalization: __post_init__ covers the direct Python constructor and,
through resolve()'s cls(**values), the env and TOML paths too; resolve()
then repoints the recorded source to default so --show-config attributes
the degraded value correctly. A valid value still resolves normally with its
real source.…a tasks.concurrency langstage.toml key and the deprecated DEEPAGENT_TASK_CONCURRENCY alias, and a malformed value is caught by the resolver and report…
langstage chat CLI and a buffered
POST /api/chat/complete endpoint (gh #101). There was no low-ceremony
"prompt in -> answer out" path. The SSE chat pair is stateful (you must open a
persistent GET /api/stream first — that's what creates the session — then
POST /api/chat, then parse the event stream; a bare POST /api/chat 404s
without the stream); check --live runs exactly one real turn but throws the
reply away; the task board returns the turn but is async, poll-based, and
persists a SQLite row. Both new surfaces are the synchronous, chat-shaped
primitive that was missing — for a CLI smoke test, a cron "ask the agent X and
email me the answer" script, or a CI assertion on the agent's actual output:
langstage chat --agent my_agent.py:graph "…" drives one turn and prints the
assistant reply to stdout (--json for {content, tool_calls}); keyless with
--demo, honors the same --workspace / langstage.toml / LANGSTAGE_*
resolution as run, and exits non-zero if the agent errors (like
check --live) so it doubles as a readiness gate that also shows the answer.POST /api/chat/complete {content, session_id?} runs one turn to completion
and returns {session_id, content, tool_calls} in a single JSON body — no
pre-opened stream, no SSE parsing, no persisted task row. It creates the
session when session_id is absent; an errored turn is surfaced as HTTP 500.
Typed with a response_model (extra="allow" + response_model_exclude_unset)
like every route since #98, so it shows up in /openapi.json and /docs.langstage/oneturn.py) that buffers the
same SessionAdapter streaming path the web server drives — the reply is
exactly what a browser would render, just assembled — so the streaming chat
path is untouched (a bare POST /api/chat still 404s without a stream).LANGSTAGE_TASK_CONCURRENCY now resolves through the unified config chain
instead of a raw os.getenv (gh #102). It is a documented LANGSTAGE_* env
var (README → Task board), but unlike every other one it was read directly with
int(os.getenv("LANGSTAGE_TASK_CONCURRENCY", "3")) in server/main.py, outside
the resolver every documented option goes through — with two user-visible
consequences: it was invisible to langstage --show-config / config (a
user who set it had no way to confirm what it resolved to, and no
langstage.toml key or legacy alias existed), and a bad value crashed the
server at startup with an unhandled ValueError traceback, where the exact
same misconfiguration of --port is caught by run and reported as a clean
one-line Error:. It is now a first-class AppConfig field wired into the
_ENV / _TOML maps like the other keys — so it appears in --show-config
with its value + source, gains a tasks.concurrency langstage.toml key and
the deprecated DEEPAGENT_TASK_CONCURRENCY alias, and a malformed value is
caught by the resolver and reported as a clean CLI error. The TaskRunner
still clamps the resolved value to >= 1, so the effective bound is unchanged.langstage.toml
(gh #100). The line read "Python args > CLI args > environment variables >
defaults" — dropping the exact langstage.toml layer that section teaches the
reader to create with langstage init, and contradicting both the
init-generated file header ("… > env vars > this file > defaults") and the
--show-config help ("defaults < langstage.toml < env < CLI"). It now reads
"Python args > CLI args > environment variables > langstage.toml > defaults",
matching the tool's own output and the resolver's real four-layer behavior; the
identical statement in the CoworkApp docstring was corrected to match.`/openapi.json` now actually describes response bodies, so it can be fed to a client generator as the README promises (gh #98). The README sells the s
/openapi.json now actually describes response bodies, so it can be fed to a client
generator as the README promises (gh #98). The README sells the served schema as the
canonical reference for a programmatic client "instead of reverse-engineering shapes", but
all 32 /api/* routes had "schema": {} for their 200 response — no route declared a
response type, so openapi-generator typed every return as untyped object/any and an
author still had to reverse-engineer every payload from source or live traffic. Request params
and bodies were typed; responses, the one thing the README said you could skip, were not.
Every JSON route now declares a response_model, and the four non-JSON routes
(/api/files/download, /api/canvas/assets/*, /api/stream, /api/custom-css) declare their
real media type — application/octet-stream, text/event-stream, text/css — instead of
advertising an empty JSON body, which would have led a generator to emit a JSON-decoding
client for a byte stream. The issue's own metric goes from 32/32 empty to 0/32.
Two deliberate safeguards, because attaching a response_model to a live route changes
runtime behavior and not just documentation:
extra="allow" on every model. FastAPI filters responses through the response model,
silently dropping undeclared keys. The pre-existing (and entirely unused) models.py had
already drifted — AppConfigResponse declared 7 fields while GET /api/config returns 12 —
so wiring it naively would have quietly deleted save_workflow_prompt, run_workflow_prompt,
create_workflow_prompt, show_canvas and show_files from the payload the SPA boots on.response_model_exclude_unset=True on every route. Typing a route can add keys too: an
optional field with a None default renders as an explicit null the old wire never sent
(FileEntry.children did exactly this). Excluding unset fields keeps each body
byte-identical to what the handler produced, so this release is schema-only at the wire.Model shapes were captured from a live --demo server rather than inferred from source. A
regression test asserts no /api/* route advertises an empty schema, that every $ref
resolves, that non-JSON routes declare their real media type, that /api/config still carries
all 12 fields, and that every model in models.py sets extra="allow" — so a future model
added without it can't start silently filtering a live endpoint.
A missing bundled frontend is now a loud, machine-readable condition instead of a browser-only surprise (gh #96). 0.13.24 stopped frontend-less wheels
langstage run printed a happy URL, uvicorn logged Application startup complete, /api/health returned {"status":"ok"}, and langstage check passed. The only
way to find out was to open a browser and get raw JSON where the workspace should be — which
is precisely how the 0.13.20–0.13.23 cut sat unnoticed on PyPI (gh #94). The absence now
surfaces at three touch-points, all driven by one shared frontend_bundled() predicate so
they cannot drift apart:
WARNING: line to stderr (like the non-loopback auth warning
from #89, so it stands out from the banner and survives stdout redirection) naming the
missing artifact, the consequence, and both fixes./api/health?ready=1 — a checks.frontend field ("ok" / "missing"), giving uptime
monitors, k8s, and CI a hook to catch a broken deploy that a plain 200 hides. It is
reported, not gating: the readiness verdict is computed before it is added, because the
REST/WS surface is fully functional without the SPA and a backend-only install is supported
(the packaging hook honours LANGSTAGE_SKIP_FRONTEND_BUILD=1). Failing readiness would pull
a working API out of a load balancer.langstage check — a [warn] bundled frontend missing … line and a checks.frontend
object under --json, so a preflight can assert the UI actually shipped, not just that the
agent loads. Warning-level: the exit-code contract is unchanged.A clean `pip install langstage` now actually ships the web UI — `GET /` serves the React SPA, not the JSON placeholder (gh #94). Regression in 0.13.20
pip install langstage now actually ships the web UI — GET / serves the
React SPA, not the JSON placeholder (gh #94). Regression in 0.13.20–0.13.23: the
published wheels contained no langstage/static/ at all, so a fresh install fell
through to the "no pre-built frontend" placeholder branch in server/main.py and the
entire chat workspace — the whole point of the stage — was missing. Root cause was pure
packaging: the Vite build output (langstage/static/) is git-ignored, and
[tool.hatch.build] artifacts = ["langstage/static/**"] only re-includes git-ignored
files that already exist at build time — it never builds them. Nothing in the manual
uv build release path ran npm run build first (the old build_frontend.py was an
untracked dev script), so the wheel was assembled with an empty tree and shipped no UI.
Fixed with a Hatchling wheel build hook (hatch_build.py) that builds the SPA
(npm ci && npm run build) into langstage/static/ before the wheel is packed, with a
skip-if-already-built guard and a publish guard that fails the build if
langstage/static/index.html is still missing — so an empty-UI wheel can never reach
PyPI again. The hook is a no-op for the sdist and for editable installs (pip install -e)
and honours LANGSTAGE_SKIP_FRONTEND_BUILD=1, so Node is only needed to cut a real
release wheel — the Python test/dev loop and a backend-only pip install . don't require
it. Publishing 0.13.24 replaces the broken 0.13.20–0.13.23 cut.`langstage run` / `check` no longer print an EMPTY error when the load exception has no message (gh #92). A follow-on gap from #90: run broadened its
langstage run / check no longer print an EMPTY error when the load exception has no
message (gh #92). A follow-on gap from #90: run broadened its except to surface loader
failures as Error: <str(e)>, but str() is empty for a message-less exception — most
commonly NotImplementedError(), which BaseChatModel.bind_tools() raises for any model that
doesn't support tool-calling, so create_react_agent(model, tools=[...]) produces a bare
NotImplementedError('') at import. The result was Error: / [fail] failed to load: with
nothing after the colon — zero diagnostic signal at exactly the moment run/check exist
to help. Both surfaces now fall back to the exception class name when the message is empty:
run prints Error: NotImplementedError, check prints [fail] failed to load: NotImplementedError, and check --json reports "error": "NotImplementedError" (the dangling
": " trimmed). The human check line and the --json error now share one formatter so they
can't drift, and a non-empty message is unchanged (still Type: message). Exit code stays 1.`langstage run --agent ` reports a bad spec as a clean one-line error, not a raw traceback (gh #90). run only caught RuntimeError around building the
langstage run --agent <spec> reports a bad spec as a clean one-line error, not a raw
traceback (gh #90). run only caught RuntimeError around building the app (the
missing-deepagents-extra case, #46), so every other common --agent mistake — a path
that doesn't exist (FileNotFoundError), a file with no such attribute (AttributeError),
or a malformed spec missing the required :attr suffix (ValueError) — escaped as a
multi-frame Python traceback for what is usually a one-character typo. That contradicted the
clean-error intent of #46 and was inconsistent with the sibling check, which already
reports the identical failures as [fail] failed to load: …. run now catches the loader's
exception classes too (ValueError / FileNotFoundError / AttributeError / ImportError,
alongside RuntimeError) and surfaces them as Error: … (a click.ClickException),
matching check's error UX. Exit code stays 1.`app.run()` now just works in a Jupyter notebook — no extra code (gh #87). The documented Python API (CoworkApp(...).run() / run_app(...)) was unusabl
app.run() now just works in a Jupyter notebook — no extra code (gh #87). The documented
Python API (CoworkApp(...).run() / run_app(...)) was unusable from a notebook: run()
calls uvicorn.run(), which wraps asyncio.run(), and a Jupyter kernel already has a running
event loop — so it died with RuntimeError: Cannot run the event loop while another loop is running, after printing the LangStage: http://… banner, so it looked like it was starting.
Notebook users are squarely in the audience (the family ships langstage-jupyter), yet the
README's "From Python" example was exactly what failed there; the only way out was a manual
threading.Thread(...) wrapper. run() now detects an already-running event loop and serves
on a background thread, returning a BackgroundServer handle immediately — the cell doesn't
block, the kernel stays interactive, and handle.stop() shuts it down. Scripts and the CLI are
unchanged (no running loop → run() blocks exactly as before). A failed bind (port in use) now
raises a clean, actionable error instead of silently killing the server thread — and no longer
risks taking the whole kernel down via uvicorn's sys.exit(STARTUP_FAILURE).`/api/files/delete` now accepts `path` as a query parameter and a `DELETE` verb, so the documented `delete?path=P` round-trip actually works (gh #81).
/api/files/delete now accepts path as a query parameter and a DELETE verb, so the
documented delete?path=P round-trip actually works (gh #81). The README advertises delete
as symmetric with read / download / upload (all query-param path) and promises
upload?path=P round-trips with delete?path=P — but delete only accepted POST + a JSON
body {"path": ...}, so DELETE …?path=P returned 405 and POST …?path=P returned 422, and a
client written straight from the docs silently left files behind. delete now takes path as
a query parameter via either POST or DELETE, matching the other files routes; the
POST + JSON body form still works (the file-browser UI uses it). /openapi.json reflects
both methods + the path parameter.`/api/files/upload` now treats `path` as the full destination path, symmetric with every other files route (gh #75). upload alone interpreted path as
/api/files/upload now treats path as the full destination path, symmetric with every
other files route (gh #75). upload alone interpreted path as a parent directory and
appended the multipart filename, while read/preview/download/delete/mkdir treat
path as the full target. So a path-symmetric client doing POST upload?path=P then
GET read?path=P didn't get its file back — the upload silently landed at
P/<multipart-filename> (creating a stray directory P), returned 200, and the read of
P then failed. Now upload?path=P stores the file at P and round-trips. The
directory-drop mode is still available explicitly — end path with /, or point it at
an existing directory, and the multipart filename is appended (this is what the file-browser
UI uses, so it's unchanged). The OpenAPI description and the README REST section document
the contract.`langstage check --json` and `langstage config --json` — machine-readable diagnostics so the advertised CI readiness gate is actually gateable (gh #73
langstage check --json and langstage config --json — machine-readable diagnostics
so the advertised CI readiness gate is actually gateable (gh #73). check produced 6+
distinct signals that collapsed into a coarse exit code (only a load failure or a --live
runtime error set exit 1; every static finding was a warning that left exit 0), so CI could
gate on almost nothing the command discovered. check --json now emits a stable object —
{spec, loads, agent_name, checks:{checkpointer, canvas, write_todos, async_tasks, schedules}, live, ok} — preserving the exit-code contract, so a pipeline can gate on any
individual check, e.g. langstage check -a app.py:graph --json | jq -e '.loads and .checks.canvas.ok'. config --json emits each field's value + source (and the TOML files
read) so a deploy step can assert how a container resolved its env / langstage.toml. The
human default output is unchanged.The built-in OpenAPI/Swagger docs are now advertised as the canonical REST reference (gh #71). Because the backend is FastAPI, a complete, always-in-s
/openapi.json, /docs (Swagger UI), and /redoc — but it was mentioned
nowhere, so a client author had to reverse-engineer request shapes. A new README REST
API section points at all three, and langstage run prints the docs URL on startup.version="2.0.0"; it now uses the installed langstage
version, with a titled/described schema, so /openapi.json and /docs are accurate.Readiness (/api/health?ready=1) now checks the agent is *runnable*, not just non-None (gh #69). The `agent` check was `agent is not None` — vacuous (a
/api/health?ready=1) now checks the agent is runnable, not just
non-None (gh #69). The agent check was agent is not None — vacuous (a failed
load aborts startup, so a serving process always has a non-None agent) and blind to
the common BYO slip of exporting an uncompiled StateGraph: it loaded, so readiness
said 200 ok, yet every turn died with 'StateGraph' object has no attribute 'aget_state'. A k8s / ALB probe would mark the pod Ready and route traffic to a server
that fails every turn. Readiness now gates on runnability (callable(agent.astream)) —
the same check langstage check uses (gh #39) — and returns 503 not_runnable otherwise.A real health/readiness endpoint: `GET /api/health` (gh #67). /health (and every non-/api/* path) returned the SPA index.html — HTTP 200 regardless of
GET /api/health (gh #67). /health (and every
non-/api/* path) returned the SPA index.html — HTTP 200 regardless of backend state,
and 401 once auth was enabled — so a reverse proxy / k8s / uptime probe had no usable
liveness signal. The new endpoint is dedicated JSON under /api/* (so it can't collide
with the SPA catch-all) and exempt from Basic Auth (a probe can't carry credentials):
200 {"status": "ok", "version": …} — the process is up;?ready=1) → 200 only if the agent object loaded and the task store
is reachable, else 503 {"status": "degraded", "checks": {…}} — reflecting real
backend state instead of the always-served static shell.Migrated the deprecated `@app.on_event` startup/shutdown handlers to a FastAPI `lifespan` context manager (gh #61). Same behavior (task store + durabl…
--workspace no longer doubles the agent's working directory (gh #66).
With a relative workspace (e.g. the README Quickstart's --workspace ./workspace),
the agent was told — and every bring-your-own file/canvas tool read — a doubled
ws/ws working directory, while the file browser and durable .langstage state lived
at ws: the exact split-brain the 0.12.2 note claimed fixed. Root cause was in core
(workspace_root() re-resolving a relative root after run() chdir'd into the
workspace); fixed in langstage-core 1.0.9, now the minimum pin. Absolute and
default workspaces were unaffected.@app.on_event startup/shutdown handlers to a FastAPI
lifespan context manager (gh #61). Same behavior (task store + durable checkpointer
upgrade + scheduler/runner lifecycle); silences the FastAPI deprecation. Thanks
@AshleyAHuang.tasks/sqlite_store.py for the ruff E702 lint
(gh #65). Thanks @AshleyAHuang.A bare graph's default name "LangGraph" no longer becomes the app title (dogfood F3). A compiled StateGraph gets .name == "LangGraph" by default, whic
StateGraph gets .name == "LangGraph" by default, which the app
used as its header title/agent name for a BYO agent — a confusing brand. Generic
names (LangGraph, agent, graph) are now ignored, so the LangStage default is
kept; a real agent .name still becomes the title.langstage SVG banner (was a cover.png labelled
"Cowork Dash", the old name).A bring-your-own agent's files now land in the workspace, visible in the file browser (ADR 0006, dogfood F7). An agent that writes Path("out.txt").wri
Path("out.txt").write_text(…)
(a raw cwd-relative path) used to write to the server's launch directory — invisible
in the file browser (rooted at the workspace) — so the agent could say "saved to the
workspace" while the browser showed it empty. run() now chdirs to the resolved
workspace after the agent spec is resolved and the server is wired (both use the
absolute path, so they're unaffected), matching the cli. Embedding CoworkApp
programmatically has no cwd side effect (the chdir is in run(), not __init__).The `[Working directory: …]` context the chat prepends to each message now reports the real filesystem workspace, not the frontend's virtual path (dog
[Working directory: …] context the chat prepends to each message now
reports the real filesystem workspace, not the frontend's virtual path (dogfood).
It used the file browser's current folder verbatim, so an agent at the browser root
was told [Working directory: /] — misleading, and actively wrong for a
bring-your-own agent that resolves paths against it. It now reports
core.workspace_root() with the browsed subfolder applied (e.g. <workspace>/notes),
so the agent hears where it actually operates.The workspace is now one source of truth, not a hand-synced mirror (ADR 0005). CoworkApp.__init__ used to reconcile the #44 split-brain imperatively —
CoworkApp.__init__ used to reconcile the #44 split-brain imperatively — resolving
the workspace, then assigning config.WORKSPACE_ROOT and two env vars so the agent's
bash/file/canvas tools agreed with the file browser. It now calls
core.apply_workspace(self.config.workspace_root) once, and config.WORKSPACE_ROOT
is a live view of core.workspace_root() (via module __getattr__) — so the
file browser and the agent tools read the same value by construction, with no mirror
to drift. Behavior is unchanged (the full #44 regression suite passes); the split-brain
is now structurally impossible. Requires langstage-core>=1.0.7.`langstage check --live`: run one real turn as a true readiness gate (ADR 0004). The static check proves the agent is a runnable graph (gh #39) but no
langstage check --live: run one real turn as a true readiness gate (ADR 0004).
The static check proves the agent is a runnable graph (gh #39) but not that it
can actually complete a turn — a bad key, a tool that fails at runtime, or a
broken state schema all pass static and die at first chat. --live runs one real
turn through the shared langstage-core preflight (core.verify()) and fails the
check (exit 1) if it errors. Default check is unchanged — still fast, static,
and keyless — so nothing breaks; --live is opt-in for when you have a working
model and want the real gate. Requires langstage-core>=1.0.6.Canvas auto-detection never fired for a bring-your-own agent (gh #48). CanvasMiddleware does its work in wrap_model_call, which langchain/deepagents f
CanvasMiddleware does its work in wrap_model_call, which langchain/deepagents
fuse into the model node — leaving no .middleware attribute or graph node — so an
agent that attached CanvasMiddleware() via create_deep_agent(middleware=[...])
was undetectable: the Canvas tab never auto-appeared and langstage check reported
"no CanvasMiddleware". CanvasMiddleware now defines a no-op before_agent hook, so
langchain compiles a named CanvasMiddleware.before_agent graph node, and
agent_uses_canvas_middleware detects it by node name (covering CanvasMiddleware
and any subclass) in addition to the stashed-attribute path the bundled default uses.`langstage run` crashed on a clean `pip install langstage` (gh #46). The built-in default agent was built at module-import time, so on an install with
langstage run crashed on a clean pip install langstage (gh #46). The
built-in default agent was built at module-import time, so on an install without
the deepagents extra it dumped a traceback — and the remediation named the wrong
package (langstage-core[demo] instead of langstage[deepagents]). The import is
now defensive (falls back to agent = None), and create_default_agent raises a
clean, correctly-packaged error that langstage run shows as a one-line message
(pip install "langstage[deepagents]", or use --demo, or pass --agent) instead
of a traceback.AG-UI is now the chat/board's only streaming path (ADR 0003). The SessionAdapter streams every turn through langstage-core's in-process AG-UI adapter,
SessionAdapter streams every turn through langstage-core's in-process AG-UI
adapter, emitting the same SSE frames the React frontend already consumes —
so the UI is unchanged. Removed the LANGSTAGE_AGUI opt-in env and the
AppConfig.agui toggle (they gated a path that no longer exists); the adapter is
constructed without agui=/stream_mode=.langstage-core 1.0 (the rename of langgraph-stream-parser;
ADR 0003). The AG-UI runtime (ag-ui-langgraph[fastapi], via core's [agui]
extra) moved into base dependencies — since AG-UI is the only path, a bare
pip install langstage must run a turn. (fastapi + uvicorn were already base
deps.) The [agui] extra is now a redundant no-op alias.experimental.agui TOML key / LANGSTAGE_AGUI env / AppConfig.agui field.
The frontend's content-delta accumulation was already AG-UI-native, so chat
rendering is unchanged.Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
`POST /api/cron` (and the `schedule_run` agent tool) returned `next_run: null` on a running server. add_job computed next_run synchronously only when
POST /api/cron (and the schedule_run agent tool) returned next_run: null on a running server. add_job computed next_run synchronously only
when the scheduler hadn't started yet; on a live server it deferred to the run
loop, which hadn't fired when the create response / tool message was
serialized — so the two places a user looks right after creating a schedule
showed no next fire time (a follow-up GET already had it, and the agent
always said "Next run: pending"). next_run is now computed synchronously in
add_job regardless of started state; the run loop keeps refreshing it.
(Found by the dogfood routine, gh #37.)`--show-config` reported `auth_username` as empty when the effective default is `admin`. The admin default lived only in the auth middleware (auth_use
--show-config reported auth_username as empty when the effective default
is admin. The admin default lived only in the auth middleware
(auth_username or "admin"), not the config layer that --show-config
renders — so a user enabling auth and consulting the documented config
inspector to find their login username saw a blank, while the server enforced
admin. The default now lives in config.py (auth_username: str = "admin"),
so --show-config, --help, the README table, and the runtime all agree.
(Auth stays inert unless a password is set, so this has no security effect.)
(Found by the dogfood routine, gh #35.)/api/files/* returned 500 on a path that escapes the workspace. The workspace boundary held (no traversal — `_resolve_path` raised `ValueError("Path e
/api/files/* returned 500 on a path that escapes the workspace. The
workspace boundary held (no traversal — _resolve_path raised
ValueError("Path escapes workspace: …")), but on read, preview, and
tree that error propagated uncaught into a generic 500, while the sibling
routes (download/mkdir/delete) already mapped it to a clean 400. Those
three now return 400 too, matching the rest of the file API. (Found by the
dogfood routine.)Workspace split-brain: the agent's `bash`/file tools ignored `LANGSTAGE_WORKSPACE_ROOT`. 0.11.2 fixed default_agent.py to honor the canonical var, but
bash/file tools ignored
LANGSTAGE_WORKSPACE_ROOT. 0.11.2 fixed default_agent.py to honor the
canonical var, but config.py's module-level WORKSPACE_ROOT / VIRTUAL_FS
still read only the legacy DEEPAGENT_* names — and tools.py uses
config.WORKSPACE_ROOT as the bash cwd. So with only LANGSTAGE_WORKSPACE_ROOT
set, the file browser used the canonical workspace while bash/file tools ran in
cwd (the 0.11.2 entry over-claimed the fix). config.py is now canonical-first
(legacy fallback + warning) for both vars, and is the single source —
default_agent.py imports config.WORKSPACE_ROOT instead of resolving its own
copy, so the agent and tools can never disagree again. (gh #-dogfood)Your coding agent can read these notes before it upgrades. Set up the MCP server →