langstage-vscode
Chat with your own LangGraph agent from inside VS Code — in the
same chat panel as Copilot — via the @langstage chat participant.
Renamed from deepagent-vscode (the old package name now just installs this one;
python -m deepagent_vscodeand the old sidecar command still work).
It has two parts in one repo:
extension/— a TypeScript VS Code extension that registers the@langstagechat participant and renders agent output in the chat view.langstage_vscode/— a small Python stdio sidecar that loads your agent and streams its events. Built onlangstage-core, so it speaks the same typed event vocabulary as the other LangStage stages (langstage,langstage-jupyter,langstage-cli).
┌─ VS Code chat panel ────────────────────────────┐
│ @langstage (TypeScript extension) │
│ │ spawns │
│ ▼ │
│ python -m langstage_vscode (stdio sidecar) │
│ │ NDJSON over stdin/stdout │
│ ▼ │
│ your LangGraph / deepagents agent │
└──────────────────────────────────────────────────┘
Status: early. The extension is not yet on the VS Code Marketplace (run it from source for now), and interactive approval of human-in-the-loop interrupts is not wired into the chat UI yet (the sidecar already supports the round-trip, and
--replcan drive it end to end from the CLI).
Every stage for your LangGraph agent
langstage-vscode is the VS Code stage of the LangStage family: write your agent once — any LangGraph CompiledGraph — and run it on every stage with the same spec string (module:attr or path/to/file.py:attr), the same langstage.toml config file, and the same LANGSTAGE_* environment variables.
| Stage | Package | Try it |
|---|---|---|
| Web app | langstage | langstage run --agent my_agent.py:graph |
| JupyterLab | langstage-jupyter | pip install langstage-jupyter, then the chat sidebar in jupyter lab |
| Terminal | langstage-cli | langstage-cli -a my_agent.py:graph |
| VS Code | langstage-vscode | you are here |
| Reference agent | langstage-hermes | LANGSTAGE_AGENT_SPEC=langstage_hermes.agent:graph on any stage |
| Shared core | langstage-core | typed events + config resolver + AG-UI bridge behind every stage |
📖 Full documentation: https://dkedar7.github.io/langstage-docs/
Serve over AG-UI
The sidecar already streams every turn through the in-process AG-UI adapter. Your
agent — any LangGraph CompiledGraph — can also be served over the
AG-UI protocol as a standalone HTTP
endpoint, without changing your agent code:
pip install "langstage-core[agui]"
langstage-agui --agent my_agent.py:graph
Install
Sidecar (Python)
pip install langstage-vscode
--demo (the keyless echo stub) runs on this base install — since 0.5.0 the base
deps pull the AG-UI runtime, which brings langgraph, so no extra is needed.
Pass --demo=tools for the rich-frame demo that exercises
tool_start/tool_end/reasoning/interrupt keyless — the extension's headline
rendering surface, without an agent or API key (parity with langstage-agui --demo=tools).
Extension (from source, until it's on the Marketplace)
cd extension
npm install
npm run compile
Then press F5 in VS Code (with the extension/ folder open) to launch an
Extension Development Host with @langstage available.
Configure
In VS Code settings:
| Setting | Description | Default |
|---|---|---|
langstage.agentSpec |
Your agent, as path/to/agent.py:graph or module:graph |
(falls back to LANGSTAGE_AGENT_SPEC / langstage.toml) |
langstage.pythonPath |
Python interpreter that has langstage-vscode installed |
python |
The sidecar resolves its configuration through the family-standard chain —
defaults < langstage.toml (global + project) < LANGSTAGE_* env < CLI
flags — so a project with [agent] spec = "my_agent.py:graph" in its
langstage.toml needs no VS Code setting at all. Inspect the resolved values:
langstage-vscode-sidecar --show-config
A relative path in a langstage.toml ([agent] spec = "agent.py:graph", [workspace] root)
resolves against that file's directory, so the sidecar behaves the same from any
subdirectory of the project; a relative --agent / LANGSTAGE_AGENT_SPEC resolves against the
directory you launched from. A leading ~ expands everywhere, and stray whitespace around the
spec is ignored. A [configurable] table is forwarded verbatim to your graph's
config["configurable"] on every turn and shown by --show-config:
[agent]
spec = "my_agent.py:graph"
[configurable]
model = "claude-sonnet-4-5" # read in a node via config["configurable"]["model"]
--show-config --json lists every config file it read under toml.paths (the global
~/.langstage/config.toml first), and reports a langstage.toml that exists but doesn't parse
as "malformed": true with the parse error, rather than as absent.
Preflight the interpreter and your agent before wiring up chat — --selfcheck
(alias --smoke) loads the configured agent (or the demo stub), asserts it's a
runnable graph, drives one turn, and exits 0 (healthy) / non-zero with a precise
message (add --json for a machine-readable verdict):
langstage-vscode-sidecar --selfcheck # validate the runtime via the demo stub
langstage-vscode-sidecar --selfcheck --agent ./my.py:graph # validate the configured agent
--selfcheck answers "is the runtime healthy?"; --message answers "what does my
agent actually say?" — it drives one turn with your prompt and prints the reply, then
exits (no NDJSON + shutdown to hand-craft). Add --json to get the raw event frames
instead of the assembled text:
langstage-vscode-sidecar --demo --message "hello" # prints the reply
langstage-vscode-sidecar --agent ./my.py:graph --message "summarize the repo"
langstage-vscode-sidecar --agent ./my.py:graph --message "hi" --json # raw event frames
Bare --demo is the echo stub (only content frames); --demo=tools serves the
rich-frame demo — a keyless way to see every non-content frame the extension renders,
no agent and no API key. Its trigger phrases route to each frame type:
langstage-vscode-sidecar --demo=tools --message "please use a tool" --json # tool_start/tool_end/extraction
langstage-vscode-sidecar --demo=tools --message "think about it" --json # reasoning frames
langstage-vscode-sidecar --demo=tools --message "ask me first" # HITL interrupt, exits 2
langstage-vscode-sidecar --demo=tools --repl # answer the interrupt inline
--message answers "what does my agent say once?"; --repl answers "does it
remember?" — the multi-turn companion to --message. It reads one prompt per line and
drives a turn, but keeps one long-lived session (a single session_id, so a single
LangGraph thread_id) alive for every turn — the same per-conversation shape the VS Code
extension uses — so your agent's memory persists across turns. That makes the memory
behavior below verifiable from the CLI in ten seconds: tell it your name, ask on the next
line. Exit with Ctrl-D (EOF) or a :quit line; --json streams
the raw event frames instead of the assembled text, just like --message:
langstage-vscode-sidecar --agent ./my.py:graph --repl
> my name is Kedar
...
> what is my name?
...
> :quit
Your agent will recall the first line on the second even if you never compiled in a
checkpointer — within one sidecar process the sidecar auto-attaches an in-memory checkpointer
to any graph that lacks one, so --repl (one process, one session_id) verifies in-process
memory and catches a wrong-session_id mistake before wiring up the extension. What it can't
prove is durable memory: that in-memory state is lost when the process ends, so persistence
across separate processes still needs a persistent checkpointer (see the memory note under
Sidecar protocol).
Both turn-drivers are interrupt-aware. If your agent pauses on a human-in-the-loop
interrupt(...) (the common deepagents / LangGraph approval pattern), the turn is no longer
a silent blank — the pending action and the decisions it allows are surfaced on stderr
(stdout stays the clean reply channel), and one-shot --message exits with a distinct code
2 so an interrupt is scriptable, distinct from a clean reply (0) or an error (1):
$ langstage-vscode-sidecar --agent ./hitl.py:graph --message "do it"
interrupt: agent paused awaiting a decision
action: confirm allowed: reject | edit | respond | approve
resume by sending a `decision` command (add --json to see the full request)
$ echo $?
2
With --json, the raw {"type": "interrupt", ...} frame streams on stdout, so a consumer keys
on it directly.
--repl can also answer the interrupt, completing the interrupt → decision round-trip
without hand-writing the stdio protocol. When a turn ends on an interrupt, the session enters
decision mode: the next line becomes a decision on the same session, so it resumes that
thread's pending interrupt.
$ printf 'do it\napprove\n:quit\n' | langstage-vscode-sidecar --agent ./hitl.py:graph --repl
interrupt: agent paused awaiting a decision
action: confirm allowed: reject | edit | respond | approve
answer it here: `:decision <verb>` (or a bare `<verb>`) using a verb above
payloads: reject [<text>] | edit <json> | respond <text>
resumed with: {'decisions': [{'type': 'approve'}]}
- Type
:decision <verb>(same:-prefixed namespace as:quit), or just the bare verb — a bare verb is only read as a decision while an interrupt is pending; the rest of the timeapproveis ordinary chat text. - The verbs come from that interrupt's own
allowed_decisions, so an approval-only agent offers and accepts exactlyreject | approve. Payloads follow the LangChain HITL decisions:approve,reject [<text>],respond <text>,edit <json>(free text becomesmessage, a JSON object is merged in, e.g.edit {"edited_action": {"name": "confirm", "args": {}}}). - While an interrupt is pending, a line that isn't a valid decision is refused on stderr and
re-prompted with the interrupt left pending — it is never silently sent as a new message (which
would just re-interrupt and look accepted) and never swallowed.
:quitis always the way out. --jsoncomposes: the answer line emitsackwith"ref": "decision", so the trace readsready → ack message → interrupt → complete → turn_end → ack decision → content → complete → turn_end. (Both turns emitcomplete— an interrupt turn isinterrupt → complete → turn_end; it is paused, not finished-with-a-reply, so detect the pause via theinterruptframe, not the absence ofcomplete.)--replexit codes:0on a clean session (including an interrupt that was answered),1if the agent could not start at all, and2if the session ends with an interrupt still unanswered — the same "paused awaiting a decision" signal--messageuses.
You can still drive decision over the raw stdio protocol directly
({"type": "decision", "session_id": "...", "decisions": [{"type": "approve"}]}) — that is what
the VS Code extension does, since interactive approval is not wired into the chat UI yet.
Your agent is any LangGraph CompiledGraph (e.g. from deepagents), exported
under the name in the spec:
# my_agent.py
from deepagents import create_deep_agent
graph = create_deep_agent(...) # -> langstage.agentSpec = "my_agent.py:graph"
Usage
Open the chat panel and start a message with @langstage:
@langstage summarize the failing tests in this repo and propose a fix
The extension streams the agent's content, tool calls, reasoning, and todo updates into the chat response.
Sidecar protocol
The extension talks to the sidecar over newline-delimited JSON. You can drive it directly for testing:
LANGSTAGE_AGENT_SPEC=./my_agent.py:graph python -m langstage_vscode
# or with no agent and no API key at all — the keyless stub runs on a base install
python -m langstage_vscode --demo
# or exercise tool-call, reasoning, and interrupt frames keyless (no agent, no API key)
python -m langstage_vscode --demo=tools
Commands (client → sidecar), one JSON object per line:
{"type": "message", "session_id": "s1", "content": "hello"}
{"type": "decision", "session_id": "s1", "decisions": [{"type": "approve"}]}
{"type": "cancel", "session_id": "s1"} // abort the in-flight turn, keep the session
{"type": "shutdown"}
A cancel stops the turn currently streaming for that session_id cooperatively —
it emits a distinct cancelled frame (neither complete nor error) then turn_end, and
leaves the process, the session, and its in-process checkpointer alive, so the next
message on the same session_id resumes with memory intact. That is the difference from
killing the sidecar to stop a turn, which throws the conversation's memory away. A cancel
with no turn in flight for the session is answered with an error frame
(no turn in progress for session '…'), consistent with the decision/message guards.
Events (sidecar → client) — the event_to_dict() shapes from
langstage-core, plus a few protocol frames:
{"type": "ready"} // emitted once at startup
{"type": "ack", "ref": "message"} // command accepted
{"type": "content", "content": "...", "message_id": "..."} // assistant text
// a new message_id = a new assistant message
// (render a paragraph break between them)
{"type": "tool_start", "name": "...", ...} // tool call
{"type": "tool_end", "name": "...", ...} // tool result
{"type": "interrupt", "action_requests": [...]} // human-in-the-loop
{"type": "complete", "outcome": "complete"} // turn finished ("interrupted" if it paused
// on an interrupt) — see the note below
{"type": "cancelled", "session_id": "s1"} // turn stopped by a `cancel` (not complete/error)
{"type": "error", "error": "..."} // protocol error (bad/unknown command)
// OR an exception raised by the agent.
// On agent failure the turn emits this
// INSTEAD of "complete", then "turn_end".
{"type": "turn_end", "session_id": "s1"}
A client must handle
error: a malformed/unknown command, amessagewith nocontent, an invaliddecision(including a well-formed one sent when the session has no pending interrupt to resume), and an agent crashing mid-turn all emit anerrorframe. On the agent-failure path there is nocomplete— the sequence isack → error → turn_end, with any content earlier nodes already produced streamed before theerror— so don't key turn-completion offcompletealone.A rejected command is still a (zero-length) turn: a
messagewith nocontent, a malformeddecision, or adecisionwith no pending interrupt emitserror → turn_end— noack, since nothing ran — so a client that waits forturn_endalways stops waiting (gh #118).turn_endis the one frame everymessage/decisionis guaranteed to end with.Two more terminal shapes are not
complete. An interrupt turn emitsinterrupt → complete → turn_end: it does still emitcomplete, but the agent produced no reply — it is paused awaiting a decision, so detect the pause via theinterruptframe, not by the presence ofcomplete. A cancelled turn (a clientcancel) emitscancelled → turn_endwith nocompleteat all — a cancelled turn is neithercompletenorerror.
session_idand conversational memory. The sidecar maps eachsession_idto a LangGraphthread_idin the run config, and attaches an in-memory checkpointer to any graph that was compiled without one — the AG-UI adapter the sidecar streams through needs threaded state, and this avoids a hard "No checkpointer set" crash. So within one sidecar process, multi-turn memory across messages with the samesession_idworks for any graph — even a plaincreate_react_agentwith no checkpointer of its own — because the sidecar supplies the missing checkpointer for you, keyed bysession_id/thread_id. Compiling your own (graph.compile(checkpointer=...), orcreate_deep_agent(..., checkpointer=...)) chooses which checkpointer is used, not whether one turn remembers the next in-process.The real distinction is in-process vs. cross-process. The auto-attached checkpointer — like any
MemorySaver— lives only in that process, so its memory is lost when the process ends. The VS Code extension keeps one sidecar process alive per conversation — it spawns the sidecar on the first@langstagemessage and reuses that same process for every following turn (gh #54) — so that in-process memory persists across turns in chat, not just when you drive the stdio protocol by hand. Each chat conversation gets its ownsession_id(a random id minted on its first turn and carried in the chat result metadata), so two conversations never share a thread — not when you resume an earlier chat, and not across restarts with a durable checkpointer (gh #133). The process is restarted on a config change (interpreter / agent spec) and when you start a new chat; resuming an older chat after that starts it on a clean in-process thread (a durable checkpointer keeps its history). If you drive the sidecar yourself, keep one process alive and send each turn to it — a fresh process per message gets a fresh in-memory checkpointer and forgets the prior turn. What survives across separate processes (durable memory) is a persistent checkpointer (SqliteSaver,PostgresSaver, …) keyed bythread_id— the one thing the sidecar's in-memory default is not. The--replflag (see Configure) drives exactly one process with one session across turns, so you can verify this in-process memory behavior from the CLI without hand-crafting the protocol.
Development
# Sidecar
pip install -e ".[dev]"
pytest
# Extension
cd extension
npm install
npm run compile
License
MIT
Release files for langstage-vscode 0.5.28
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| langstage_vscode-0.5.28.tar.gz | 112.4 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| langstage_vscode-0.5.28-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 152.1 kB
Release files / langstage_vscode-0.5.28.tar.gz
| Download URL | langstage_vscode-0.5.28.tar.gz |
|---|---|
| Size | 112.4 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
29579d99e6db0368ed1bfdb78fb0685b68cecd9b991d4541f3b6339d8511806b
|
|
BLAKE2b-256 checksum How to use checksums |
53e8d90312b2b8ff1630f09ab41fb65f34cad018ff95f01cc17d6ed8f8e4e305
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.11.15 {"installer":{"name":"uv","version":"0.11.15","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":null,"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Release files / langstage_vscode-0.5.28-py3-none-any.whl
| Download URL | langstage_vscode-0.5.28-py3-none-any.whl |
|---|---|
| Size | 39.7 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
8c6f3f365c52272d06a93dc2d91f0e79191954d1faa2ef95ed5303ed0b1d145c
|
|
BLAKE2b-256 checksum How to use checksums |
c6e4c9cb424eeb82106ffe42e3ea16a0f1320e65afcca37765dd4ae5b53ab368
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.11.15 {"installer":{"name":"uv","version":"0.11.15","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":null,"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|