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).
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/
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:graphpip 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).
cd extension
npm install
npm run compileThen press F5 in VS Code (with the extension/ folder open) to launch an
Extension Development Host with @langstage available.
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-configPreflight 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 framesBare --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 a checkpointer-backed agent's memory persists across turns. That
makes the checkpointer caveat 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?
...
> :quitAn agent compiled with a checkpointer (graph.compile(checkpointer=MemorySaver())) will
recall the first line on the second; one without a checkpointer won't — which is exactly the
missing-checkpointer / wrong-session_id mistake to catch before wiring up the extension
(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 $?
2With --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"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.
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=toolsCommands (client → sidecar), one JSON object per line:
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": "..."} // assistant text
{"type": "tool_start", "name": "...", ...} // tool call
{"type": "tool_end", "name": "...", ...} // tool result
{"type": "interrupt", "action_requests": [...]} // human-in-the-loop
{"type": "complete"} // turn finished (success) — 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— so don't key turn-completion offcompletealone.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. Multi-turn memory across messages with the samesession_idtherefore only works if your agent was compiled with a checkpointer (e.g.graph.compile(checkpointer=...), orcreate_deep_agent(..., checkpointer=...)). A plaincreate_react_agentgraph with no checkpointer is stateless: the second turn won't remember the first, even with a matchingsession_id. This is expected LangGraph behavior, not a sidecar bug.The VS Code extension keeps one sidecar process alive per conversation — it spawns the sidecar on the first
@langstagemessage and reuses that same process (and the samesession_id) for every following turn — so an in-process checkpointer likeMemorySaverpersists across turns in chat, not just when you drive the stdio protocol by hand (gh #54). The process is restarted on a config change (interpreter / agent spec) and when you start a new chat, so a new conversation begins with a clean thread. 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-process checkpointer and forgets the prior turn; a persistent checkpointer (SqliteSaver,PostgresSaver, …) keyed bythread_idis what survives across separate processes. The--replflag (see Configure) does exactly this — one process, one session across turns — so you can verify this memory behavior from the CLI without hand-crafting the protocol.
# Sidecar
pip install -e ".[dev]"
pytest
# Extension
cd extension
npm install
npm run compileMIT
{"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"}