GitHub’s multi-model Copilot harness comes to Microsoft Foundry for building custom AI agents
Microsoft has introduced the GitHub-built Copilot harness in Foundry Agent Service in public preview. Developers can supply their own instructions, skills and tools while Foundry manages the agent loop and execution environment.
The open-source connection is the Copilot SDK, which is available under the MIT license. It lets developers integrate Copilot’s agent capabilities into their own applications, with SDKs for Python, TypeScript, Go, .NET, Java and Rust.
There is a licensing distinction: the SDK communicates with Copilot CLI, which has a separate license restricting modification of the CLI itself. The SDK’s MIT license does not make the entire agent runtime open source.
The Foundry preview also supports Model Context Protocol (MCP) tools, reusable skills and agent definitions maintained alongside application code. These provide ways to customize and connect agents while the execution infrastructure remains managed by Foundry.
sources
- Foundry public preview announcement
- Copilot SDK documentation and architecture
- Copilot SDK MIT license
- Copilot CLI license
4 points · 2 comments
How I built Browy, an AI agent that lives in your browser
---
title: "How I built Browy, an AI agent that lives in your browser"
published: true
description: "Browy is an open-source AI agent that works inside the browser you're already signed into. Here's how the pieces fit together, and what broke along the way."
tags: ai, webdev, opensource, architecture
canonical_url: browyhq.github.io
cover_image: browyhq.github.io
---
When you send Browy its first message, Chrome puts up a bar saying that Browy
started debugging this browser. No extension can hide it, Browy included. It
appears because Browy drives your tabs through chrome.debugger, which lets
an extension speak the Chrome DevTools Protocol, the same protocol your own
DevTools use.
I've grown to like that bar, because it describes Browy better than I usually
manage to. Browy is an AI agent that lives in your browser, and I mean that
literally. It works in the Chrome, Edge or Brave window you already have open,
signed in to everything you're signed in to.
You chat with it in the side panel, or type at it in a DevTools panel, and it
reads pages, clicks, types, fills forms and runs JavaScript in your tabs while
you watch. The models come through your own GitHub Copilot subscription. It's
open source under Apache-2.0, and I've
been building it on my own since April. This is the long version of how it
works, with the bugs left in.
What happens when you hit send
Three programs on your computer take part, and they talk over pipes.
The first is your browser, running Browy's extension: a side panel for
chatting, a DevTools panel for people who'd rather type commands, and a
settings page. None of them talk to the model. They talk to the extension's
service worker, which keeps one connection open to the second program. The
frontend architecture page walks through the extension file
by file.
That connection is Chrome's native messaging. Browy's installer registers a
manifest under the name com.browy.host, and when the service worker calls
chrome.runtime.connectNative('com.browy.host'), Chrome starts the host and
wires the extension to its stdin and stdout. Each message is a four-byte
length followed by JSON. The open port also keeps the service worker alive,
which Manifest V3 would otherwise put to sleep.
The host is a Node program, and it's where the agent lives. It wraps GitHub's
Copilot SDK, which starts the third
program, the Copilot CLI, in headless
mode and talks JSON-RPC to it over another pair of pipes. The CLI makes the
model calls on your Copilot subscription and stores each chat. Browy's code
covers the browser half: the tools, and the picture of the page the model gets
on every turn. The backend architecture page has the
details.
That picture is rebuilt each time you send a message. The host appends a
<browser_context> block to your text, with the active tab's title and URL,
the viewport, the scroll position and any text you've selected, followed by a
snapshot of the page and the agent's last few tool calls. Your words go first,
so the chat's summary in the history list shows what you asked.
When the model calls a tool, the SDK runs a handler in the host. If the tool
needs the page, the host sends a cdp.send message back up the native port,
naming a DevTools Protocol method and its parameters. The service worker picks
the tab, attaches chrome.debugger if it isn't attached yet, runs the command
and sends the result back down. Network and console events travel the same
route. The host coordinates, and every action happens in your browser.
The DevTools panel is the same agent with a terminal in front: slash commands,
a model picker, and a /js mode that runs each line in the inspected page
without calling the model. Each panel works on the tab it inspects. The
DevTools CLI page covers how the REPL is built.
The page as a numbered list
Ask a model to click something by CSS selector and it will happily invent one.
Even a correct selector only says where an element sat at one moment, and a
re-render can move it.
So Browy hands the model a numbered list. One Runtime.evaluate call walks
the page, shadow roots and same-origin iframes included, and keeps each
element that's interactive, visible and within 2,000 pixels of the viewport.
It sorts them in reading order, drops any that sit almost entirely inside
another, stops at 150 and stamps each one with a data-browy-id attribute.
The model sees something like this:
[1]<a href=/expenses>My expenses</a>
[4]<input type=text name=title placeholder="e.g. Client dinner" aria-name=Title />
[5]<input type=number name=amount aria-name="Amount (SGD)" />
[6]<input type=checkbox name=billable unchecked aria-name="Billable to client" />
[*7]<button>Attach receipt</button>
[~12]<button type=submit>Submit for approval</button>
A ~ marks an element that's off screen, which the click tools scroll into
view first. A * marks one that wasn't in the previous snapshot, which is how
the model notices that a dialog opened or a new part of the form appeared. The
aria-name is what a screen reader would announce, usually the label you'd
read yourself.
For click_index(12), the host finds the stamped element and sends real mouse
events to its center with Input.dispatchMouseEvent. Before and after, it
records the URL, the title, a hash of the visible text, the number of open
dialogs and the element's ARIA state. Half a second after the click it
compares them and reports what changed, with flags like urlChanged,
domChanged, modalAppeared and elementStateChanged. If nothing changed,
the reply says so and suggests what might be in the way, such as an overlay or
a page that's still rendering.
I didn't invent this. The indexed snapshot is modeled on
browser-use's DOM service and
serializer, down to the rule for dropping nested elements, and I'm grateful to
that team for working it out in the open.
Turning tools off
Browy has 54 browser tools, from click_index and fill_form to
get_network_requests and replay_request. Most have a switch in Browy's
settings. Turning one off removes it from two places, the tool definitions
sent to the model and the SDK's availableTools allowlist, so the model never
learns it exists.
Two of them run code the model writes. evaluate_js runs JavaScript in
the page, for the jobs the indexed tools can't express, and it's on.
run_script runs Node.js on your computer, so it's off until you switch it
on. It was on by default until version 0.1.10, which was the wrong default
for a tool that reaches your machine.
The Copilot CLI also brings tools of its own, like bash and web_fetch,
which act on your computer instead of the tab. Browy keeps those out of the
allowlist until you switch them on, one at a time, the same as run_script.
This matters because Browy reads pages written by strangers while it's signed
in as you. Any page can carry text aimed at the model, and Browy's security
model assumes some pages will. The switches decide what a page like that could
talk the agent into.
Why your own browser
Plenty of agents start a fresh browser, often on a server, with an empty
profile that isn't signed in to anything. For public pages at scale, that's
the right design. Most of my repetitive work happens on pages that only exist
because I'm signed in, though: the pull request I'm reviewing, my Gmail
filters, an expense claim, a journal's submission portal. A fresh browser sent
to any of them lands on a login screen. I wrote about the form-filling side of
this in an earlier post.
Browy sidesteps that by staying in your browser. Ask about the pull request in
front of you, and it reads the pull request in front of you, in the tab where
you're already signed in. Your logins stay where you made them, in your
browser profile, next to your cookies and your password manager. When a page
wants a password or a 2FA code, the agent is meant to call await_user, which
pauses and hands the job to you. That tool's own description tells the model
to use it instead of typing into password fields.
Your chat history is stored on your machine too, under ~/.copilot/. What
leaves it is what the model needs to see: your messages and the page content
the agent reads, sent through your Copilot subscription under GitHub's terms,
the same way the Copilot CLI sends them from a terminal.
The trade is scale. Browy works in one browser on one machine, and only when
you ask. It won't run a hundred jobs overnight, and I'm fine with that.
The second install, and other costs
Browy needs two programs that live outside the browser, Node for the agent and
the Copilot CLI for the model. An extension can't install those, and native
messaging only reaches a program that's already registered on the machine. So
Browy installs in two steps: the extension from the Chrome Web Store, then a
one-line command in PowerShell or a terminal. The side panel shows the command
for your system, with a copy button, and picks up the host by itself once it's
registered. In my testing that took 3.6 seconds. The
install guide has both commands.
Having two halves also means two update schedules: the Web Store updates the
extension, and you update the host, which the side panel prompts you to do
when it falls behind.
Then there's size. The Windows download is about 140 MB, most of it the
Copilot CLI. In August I cut a 106 MB native binary out of every download,
because the SDK ran the CLI as a Node script and nothing ever executed the
binary. In October the CLI itself moved into that same package as a native
executable. Switching to it cut the CLI's startup on Windows from 5.8 seconds
to 0.6, and pushed the Windows download from about 80 MB back up to about 140.
You download Browy once and wait for it to start far more often, so I'll take
that trade.
Browy is also Chromium only: Chrome, Edge and Brave. All of the above depends
on chrome.debugger, and Firefox and Safari don't give extensions an
equivalent.
And there's the debugging bar from the top of this post. It stays up while
Browy holds a tab, and clicking Cancel on it detaches the agent.
The system prompt that never arrived
In October I noticed that Browy still bundled version 1.0.36 of the Copilot
CLI, while the current release was 1.0.95, the native binary from the last
section. Updating meant checking each SDK call Browy makes against the new
CLI, and that turned up a bigger problem.
The SDK builds its session.create request from named fields, and the system
prompt goes in one called systemMessage. Browy had been passing its prompt
as systemPrompt since the first commit. Nothing complained. JavaScript
doesn't mind an extra key on an options object, and the SDK never looked for
that one. For five and a half months, every Browy chat ran on the Copilot
CLI's default instructions plus Browy's tool list. The instructions that tell
the model it's Browy, how to use the indexed tools and when to hand a sign-in
back to you never reached it.
The SDK's TypeScript types would have caught it, since SessionConfig has a
systemMessage field and no systemPrompt. But Browy loads the SDK with
require() and holds the client as any, so the compiler never compared the
two.
The part that stings is a comment about thirty lines higher in the same
function, shipped in the very first release. It notes that an earlier
includeDefaultTools: false flag had never been a real SDK option either. I
had caught this exact kind of bug once and written it down, and the second one
sat thirty lines below my note.
I suspect Browy mostly behaved anyway because the tool descriptions did reach
the model, and a lot of the guidance lives there.
The fix was one line, systemMessage: { content: SYSTEM_PROMPT }, with a
catch. The prompt I'd written in April told the model to fill a form and then
submit it, and once the prompt started arriving, that rule would have taken
effect for the first time. So the same release rewrote it. Browy now submits
only when you ask it to submit, send, post or buy, and otherwise stops and
tells you the form is ready to check. Asked its name, it finally says "I'm
Browy."
The chats that were on disk all along
In May I removed my development copy of the extension to test a clean
install, the way a new user would get it. When I opened the new copy, my older chats were
missing from the history list. Uninstalling an extension wipes its storage, so
I assumed they were gone.
They weren't. The Copilot SDK had written every turn of every chat to its
session store under ~/.copilot/, on my own disk, and the host already knew
how to list and read those sessions. It just didn't recognize the old ones as
Browy's. It looked for session ids starting with sp- (side panel) or dt-
(DevTools), and my older chats predated that scheme.
Version 0.1.4 recognizes a Browy session by any of three marks: the id prefix,
the working directory every Browy session is created with, or the
<browser_context> block Browy adds to messages. I widened the rule for
listing chats but left the narrow one in charge of automatic cleanup, so
nothing of uncertain origin could be deleted by accident.
Chats looked lost once more in August, when "No past chats yet" turned out to
be a four-second timeout on a host that needed between 7.7 and 40 seconds to
answer. Both times, the chats were fine. That's an upside of a local agent I
hadn't planned for: when the interface said my chats were gone, I could open
the folder and check.
The form it said it had filled
On every turn, Browy shows the model its own last five tool calls in a
<recent_actions> block, with each tool's arguments, whether it worked, how
long it took and the start of its result. That's how the model checks whether
its last click did anything. The same idea shows up in browser-use and in
research agents like ReAct and SeeAct.
The list lived on the host's agent object, and one agent serves every chat. So
a new chat's first message arrived carrying the previous chat's last five
calls.
I found out on October 13, while filming the demo video that's now on Browy's
homepage. In a brand-new chat, I asked Browy to fill the same form it had just
filled in another chat. It replied that the form was filled. It hadn't called
a single tool.
The model wasn't making things up, exactly. It was reading. Its context showed
fill_form coming back ok moments earlier, in what it had every reason to
think was its own chat, and it believed the record.
The fix keys the list by chat, in
a small module of its own,
and a new test fails if a fresh chat ever starts with another chat's tool
calls again. Version 0.1.9 shipped the same day.
The demo video came out of that same filming session. It runs 65 seconds in
real time: one sentence typed into the side panel, a paper open in one tab,
and a 26-field paper submission form in the other. By the end every field is
filled, and the submit button is still waiting for me.
---
- All posts Index of every post on the Browy blog.
- Architecture: native host How the host wraps the Copilot SDK and routes tool calls back to the extension.
1 reaction · 1 comment
polyglot interactive and Microsoft agent sdk
Hello, my use project is somewhat complex for intermediate developer, since I want to develop a monolithic platform with web-GUI. the idea here is to lunch a normal .net app which have csnake runtime . alongside with embedded notebook server for relp . AND github/copilot-sdk hosted locally, preferably with onnx runtime inference, (BYOK) the idea here is to enable user to annotate scripts . I know this is NOT how things work as there' many caveats and alternative way of doing it e.g. IPC or gRPC or file based scripting instead of old school FFI with pinvoke . Also csnake has different usecase ( interactive Jupiter notebook is not poyglot in a real sense but it is different kernels came together)
but the idea here is to make .net as the backend & entry point while middleware / frontend with
AG-UI Protocol which should integrate A2A,ACP etc.. users can utilize the sdk capability to annotate tools from the different package mangers ... thus I also need to integrate MISE MCP for validation ..
what do you think ?
3 comments
Copilot remembered the bookings. Why couldn't it resume the workflow?
---
title: "Copilot remembered the bookings. Why couldn't it resume the workflow?"
published: true
tags: githubcopilot, ai, architecture, azure
canonical_url: tamirdresher.com
---
It started with a simple piece of curiosity: when I ask GitHub Copilot to create a dynamic workflow, how does it actually work behind the scenes?
You type a plain-English sentence — "build me a workflow that books a flight, a hotel, and a rental car" — and a moment later, Copilot has registered an executable multi-agent workflow in your session. Very cute. Very magical. Very "look, Mom, the agents planned my vacation."
Except I don't like magic in my runtimes. I wanted to know what was actually happening under the hood between my prompt and that running workflow. How does the model even know the workflow API? Is it baked into the model weights? Stuffed into a giant hidden system prompt? Loaded from a secret skill? Where does the generated code live on disk, and what actually executes it?
So I did what I always do when a feature feels like a black box: I started pulling threads. I traced the tool schemas through the CLI binary, inspected the session logs, and opened Procmon to watch which files the process actually touched on disk.
And of course, once I saw how the runtime builds and journals a workflow, the distributed-systems part of my brain (if you read my earlier post on Azure Durable Task Scheduler, you already know where this is going) couldn't resist asking the next question: what happens if I kill the process right after the hotel is booked, before the rental car agent finishes?
Stick with me. What started as a peek behind the curtain turned into a great lesson in the difference between having a log of what happened and having a durable workflow you can actually resume.
- * *
Peeking Behind the Curtain: How Copilot Learns to Write a Workflow
When you ask Copilot to create a dynamic workflow, there's no magic Markdown compiler turning your English into a state machine. It's a very practical, three-step handshake between the native CLI runtime and the bundled Copilot SDK.
First, the native runtime exposes the workflow tools (dynamic_workflows_manage and run_dynamic_workflow) to the model through standard tool descriptions and schemas. If you trace the strings in the CLI (1.0.94-3 in my initial trace), they lead straight through the JavaScript bridge into the bundled prebuilds\win32-x64\runtime.node.
And right inside the tool description for dynamic_workflows_manage, the runtime leaves the model a breadcrumb: call operation: "guide" before authoring any workflow code.
So that's step one. The model calls dynamic_workflows_manage({ operation: "guide" }), and the runtime hands back the local paths to the bundled SDK documentation:
%LOCALAPPDATA%\Programs\GitHub Copilot\copilot-sdk\docs\workflows.md
%LOCALAPPDATA%\Programs\GitHub Copilot\copilot-sdk\index.d.ts
(Depending on how you installed Copilot CLI, that path might live under %LOCALAPPDATA%\copilot\pkg\win32-x64\<version>\copilot-sdk\docs\workflows.md instead — which is why the guide operation returns the exact path for your active install rather than hardcoding it.)
Step two is refreshingly low-tech: the model simply calls view on that workflows.md path and reads the manual. Literally. The SDK workflow guide enters the context window as a normal tool result, teaching the model the exact shape of defineWorkflow, ctx.step, ctx.agent, and ctx.parallel.
Step three is turning that knowledge into a runnable extension. In the author flow, the model calls dynamic_workflows_manage({ operation: "author", meta, run }), passing the workflow metadata and the JavaScript run function body. The host validates it, wraps it in a small ES module scaffold under the session's extensions\<name>\extension.mjs folder, drops an .authored-workflow marker next to it, and reloads the extension into the session:
import { defineWorkflow, joinSession } from "@github/copilot-sdk/extension";
const workflow = defineWorkflow({
/* generated meta and run omitted */
});
await joinSession({ workflows: [workflow] });
Notice the clean division of labor: the model writes the meta declaration and the run body; the host writes the import and joinSession boilerplate; and @github/copilot-sdk/extension resolves to the bundled SDK inside the extension host. Creating and registering the workflow doesn't run it — execution is a separate step via run_dynamic_workflow.
Watching It Happen Live in the CLI
Reading traces is one thing; watching it happen live is much more fun. I spun up a fresh Copilot CLI session (running 1.0.99-2 through cli-tunnel) and asked it to build a Seattle trip-planning workflow — register only, don't execute yet.
!Actual live Copilot CLI input showing the Seattle workflow creation request before submission, with private fields masked.
Asking Copilot CLI to create and register the Seattle trip-planning workflow.
Right on cue, the first thing Copilot did was ask permission to read the bundled workflows.md guide from disk:
!Actual Copilot CLI permission prompt for bundled workflows.md, with private paths masked.
Copilot CLI asking permission to read the bundled copilot-sdk/docs/workflows.md guide before writing a single line of workflow code.
In this newer CLI version (1.0.99-2), instead of the single-shot author call, Copilot used the extension lifecycle directly — guide → scaffold → edit → reload → inspect — and registered seattle-trip-planning with five ctx.step operations (three parallel simulated bookings, a review step, and a guarded confirmation) without executing it:
!Actual Copilot CLI registration response for seattle-trip-planning, explicitly not run, with private identifiers masked.
The workflow is scaffolded, reloaded, and verified in the registry — ready to run, with zero bookings made yet.
Because I like to verify what happened behind the curtain, I checked the creation turn from two angles. First, I queried the session's recorded tool execution log to see the exact sequence of tool calls (guide returning the path → view reading workflows.md → scaffolding the extension → reading and patching extension.mjs):
!Genuine live Copilot CLI display of a later read-only audit's verbatim stdout for selected creation tool records, with private command-path details masked.
Inspecting the session's tool log: guide returns the SDK doc path, view reads workflows.md, and the extension scaffold gets patched in place.
And second, because nothing beats OS-level ground truth, I opened Sysinternals Process Monitor (Procmon) and watched the process touch copilot-sdk\docs\workflows.md on disk:
!Actual Procmon window showing later installed workflows.md guide accesses, with private path segments masked and limited visible columns.
Procmon catching the file system accesses to the installed copilot-sdk\docs\workflows.md guide on disk.
When you run that workflow on the happy path — say, a simulated trip from SFO to Seattle for November 10–14, 2026 — it flies through in one shot and prints a clean booking table:
!Actual Copilot CLI completion summary for three valid simulated Seattle trip bookings, with private identifiers masked.
The happy path: all three simulated bookings complete and the trip confirms cleanly on the first attempt.
- * *
What Copilot Actually Writes When You Don't Coach It
Before we break things, it's worth looking at the code Copilot generated when I gave it an open-ended prompt in a fresh desktop session:
> Create a Copilot dynamic workflow that plans a trip: given a destination, book a flight, a hotel and a rental car, review booking results, then confirm. Create/register only, do not execute or make reservations.
I deliberately didn't mention Durable Task Scheduler, idempotency keys, or replay boundaries. I wanted to see what "natural" Copilot workflow code looks like out of the box.
To its credit, it didn't write a naive "call three things and print success" script. It set up three parallel booking agents, followed by a fourth review agent, followed by a guarded confirmation step:
const services = ["flight", "hotel", "rental car"];
const results = await ctx.parallel(services.map((service) => () => ctx.agent(
`Book the ${service} for a trip to ${JSON.stringify(tripDestination)}. ` +
"Use available booking tools, actual service responses, and only travel details, constraints, and authorization already supplied by the user. " +
"If essential details, authorization, or booking tools are missing, return needs_input and explain what is needed. " +
"Return booked only after the travel service confirms the reservation, with its actual confirmation number. " +
"If the booking fails, return failed with the reason. Never invent a reservation or confirmation number.",
{ label: `Book ${service}`, schema: bookingSchema },
)));
It even added structured schema checks (booked, needs_input, failed), verified that every booking returned a non-empty confirmation number, and had the review agent validate consistency before allowing the final ctx.step("confirm-trip", ...) to confirm the itinerary:
if (!allBooked || !review.approved || review.issues.length > 0) {
return { status: "not_confirmed", destination: tripDestination, bookings, review };
}
return await ctx.step("confirm-trip", () => ({
status: "confirmed",
destination: tripDestination,
bookings,
review,
}));
As application logic goes, that's genuinely thoughtful code.
As a durable distributed workflow, though, look closely at what's missing. There are no provider idempotency keys. There's no external orchestration state mapping a logical trip to a resumable run. And wrapping the final return object in ctx.step("confirm-trip", ...) checkpoints the final summary — it doesn't magically wrap the external tool calls happening inside those three parallel ctx.agent(...) calls in durable, idempotent boundaries.
Which brings us to the fun part: pulling the plug.
- * *
Pulling the Plug Between "Booked" and "Finished"
Let's run the crash test. (And before anyone worries about my corporate Amex: all bookings in these tests hit local fake booking providers. The Copilot workflows and agents were real; the airlines were not.)
I ran that exact generated trip workflow on the default local executor (1.0.95-3) and engineered a very specific crash point:
1. Flight agent calls the flight tool, gets a simulated reservation, and finishes (flight = 1).
2. Hotel agent calls the hotel tool, gets a simulated reservation, and finishes (hotel = 1).
3. Car agent reaches the rental car tool — and I hold the request right before it creates the reservation (car = 0).
4. While the workflow is mid-flight, I kill the CLI, extension host, and provider processes.
Then I reopened the exact same Copilot session.
Here's the part that surprised me at first. This wasn't a "the process died and wiped its memory" failure. The local journal had saved the completed flight and hotel records. The fake provider receipts were sitting right there on disk. Copilot remembered the bookings.
So I asked it to resume the interrupted run. And the runtime came back with a flat refusal:
> was interrupted before it settled durably and cannot be resumed
The run was marked error (reason: interrupted, canResume: false). Even worse, the old rental car agent row was still stuck reporting running in storage after the process had died, leaving a phantom live-agent count of 1.
How Copilot Actually Maintains Its Native Ledger (and Why It Still Wasn't Enough)
To understand why Copilot remembered the bookings but still refused to continue, I looked at how the SDK and native CLI actually maintain workflow state under the hood (anchored in nodejs/src/session.ts and src\runtime\src\workflows\store.rs in the native runtime).
Copilot's default local workflow runner is not stateless. When a workflow starts, the native runtime mints a runId and an active executionToken for that attempt, and every method on ctx talks back to the native session store over JSON-RPC:
- ctx.step(key, producer) is a read-through, write-through memoization cache backed by the native ledger (session.workflow.journal.get and session.workflow.journal.put).
- ctx.agent(prompt, options) calls session.workflow.agent, which spawns the sub-agent and records a full lifecycle row (agentId, phaseId, status, resolvedModel, startedAt, completedAt, activeMs, plus consumed sub-agent and token accounting on the run envelope).
- ctx.log and ctx.phase batch ordered progress lines through WorkflowProgressBuffer and flush them via session.workflow.log.
Look at the ctx.step implementation in nodejs/src/session.ts — it's super clean:
const cached = await awaitWorkflowOperation(
() =>
self.rpc.workflow.journal.get({
runId: params.runId,
executionToken: params.executionToken,
key,
}),
controller.signal
);
if (cached.hit) {
return cached.resultJson;
}
const result = await producer();
await awaitWorkflowOperation(
() =>
self.rpc.workflow.journal.put({
runId: params.runId,
executionToken: params.executionToken,
key,
resultJson: result,
}),
controller.signal
);
return result;
So why couldn't it resume if journal.put and workflow.agent had already written the completed outputs to disk?
Because in the native store, having journal entries on disk is not what makes a run resumable. A run is only marked canResume: true when the attempt settles cleanly at a controlled checkpoint (for example, pauseAtCheckpoint). When you kill the process mid-flight while an executionToken is still active, the runtime sees an ungracefully severed attempt. On rejoin, it marks the run envelope as status: "error", reason: "interrupted", and canResume: false — and any call to session.workflow.resume (resumeFromRunId) gets rejected before it ever touches the step journal.
To verify that directly against journal.get / journal.put, I reproduced the exact same crash boundary in the 1.0.99-2 CLI with a deterministic step-only version (seattle-trip-interruption-demo). All three parallel bookings and the review step finished and were written to the native step journal via session.workflow.journal.put, and the workflow paused at a file gate right before final confirmation (WAITING_FOR_OPERATOR_RELEASE):
!Actual Copilot CLI running status and operator-release waiting log for the separate controlled-interruption demo, with private identifiers masked.
Pausing the workflow right before final confirmation — three bookings and the review step are already completed and saved in the local journal.
I killed the CLI and its workers, reopened the same conversation, and called run_dynamic_workflow with resumeFromRunId for that run. Same result:
!Actual Copilot CLI same-run resume refusal in the reopened controlled-demo conversation, with private identifiers masked.
Reopening the session and attempting resumeFromRunId: the journal still has all four completed step results, but the runtime refuses to resume the interrupted run.
All four completed step outputs were still sitting in the database byte-for-byte intact. The ledger remembered the data, but the crashed attempt lost execution authority.
That's the distinction that's so easy to miss when you only test the happy path:
Remembering completed work and being able to resume a workflow are two completely different capabilities.
A ledger tells you what happened before the crash. A durable orchestrator knows how to replay that history, re-establish execution authority, and pick up at the exact next step without re-running completed work.
- * *
Wiring in Durable Task Scheduler (and Where the Native Boundary Pushes Back)
This is the exact problem space Azure Durable Task Scheduler (DTS) was built for. So I built an experimental fork of copilot-sdk to see what it takes to plug DTS into joinSession.
I added an optional WorkflowExecutor seam so an extension can bring its own durable execution engine while defaulting to normal local behavior if omitted:
export interface WorkflowExecutor {
execute(
definition: WorkflowDefinition,
invocation: WorkflowInvocation,
host: WorkflowExecutionHost,
signal: AbortSignal
): Promise<{ result?: JsonValue }>;
}
Under the hood, the architecture splits cleanly into three pieces:
- The client schedules or attaches to a deterministic DTS orchestration instance.
- The worker pulls work items and runs the JavaScript workflow code.
- The scheduler (DTS) persists the orchestration history across process crashes.
When a worker restarts after a crash, the replay adapter (workflow-replay.ts) walks the workflow code against the recorded JSON history in DTS. Every Agent, Step, and Progress call becomes a tracked activity. If DTS already has a recorded completion for step 1, replay returns the cached JSON immediately without re-invoking the agent or step, and advances until it hits the first unseen operation.
How Recovery Actually Works: The Carrier Run Pattern
Here's the catch, though: WorkflowExecutor lives in the JavaScript SDK host, while resumeFromRunId is gated by the native CLI runtime. Teaching the JavaScript layer about DTS doesn't magically rewrite the native binary's rule that an interrupted native run ID is non_resumable.
So how do you recover an interrupted DTS-backed workflow when the native CLI refuses resumeFromRunId?
You use a new native run as a carrier for the existing DTS orchestration instance.
I tested this end-to-end using the official changed-file code review workflow (review-changed.mjs) running against a local DTS emulator. The workflow reviews two files in sequence: a cart-total calculation (which has a bug: reduce missing its initial 0 accumulator) and a discount-price function (which accidentally subtracts a percentage instead of multiplying).
1. On Run 1, the first review agent finished analyzing the cart fixture (492 bytes of structured review output) and DTS checkpointed the completion.
2. Before the second review agent could finish, I killed the process.
3. After reopening, instead of asking the native CLI to resume the dead native run ID, I launched a fresh native carrier run configured via workflow-dts.ts to attach to the original DTS instance ID (verifying that the workflow scope, arguments, and code hash matched).
It worked like a charm.
The carrier run attached to the existing DTS orchestration, replayed past the first step by reusing the exact 492-byte cart review from DTS history, and spawned only one new agent to finish the discount-file review. Across the crash and recovery, only two workflow agents ever ran — one per file.
Is it seamless UI magic yet? Not quite. Because the carrier is a new native run, the CLI UI doesn't automatically rehydrate the old Review phase progress bars from the dead run, and you still have to manage your logical run mapping and budgets across attempts (plus make sure your external tools carry idempotency keys if a worker dies right after an API call succeeds). Honest engineering trade-offs — but the core replay mechanics are rock solid.
- * *
Where This Leaves Us
I started this whole rabbit hole just wanting to know how Copilot turns a plain-English sentence into a runnable workflow.
What I love about looking under the hood here is how pragmatic the design actually is. There's no mystery compiler — just a clean guide → view → extension.mjs loop that teaches the model the SDK on demand and registers normal JavaScript into the session. And the local runtime already does the work of journaling step outputs, agent runs, and progress lines as it goes.
Where things get interesting is the gap between keeping a ledger and recovering a live workflow. When a process dies mid-flight, having the previous step results sitting on disk isn't enough if the runtime can't safely re-establish execution authority and replay past completed work.
Plugging a WorkflowExecutor seam into copilot-sdk showed just how naturally Azure Durable Task Scheduler fits that gap: you keep the exact same defineWorkflow developer experience, let Copilot author the workflow the way it already wants to, and let DTS handle the replay when reality inevitably pulls the plug.
1 comment
@okazuki ですよね。
jsonで渡す方法(TypedDict)から名前付き引数に変わっていました。
どうやらGAのときに引数が変わっていたようです。
github.com/github/copilot-sdk/blob/main/CHANGELOG…
73 views
Mentions
GitHub’s multi-model Copilot harness comes to Microsoft Foundry for building custom AI agents Microsoft has introduced the GitHub-built Copilot harness in Foundry Agent Service in public preview. Developers can supply their own instructions, skills and tools while Foundry manages the agent loop and execution environment. The open-source connection is the Copilot SDK, which is available under the MIT license. It lets developers integrate Copilot’s agent capabilities into their own applications, with SDKs for Python, TypeScript, Go, .NET, Java and Rust. There is a licensing distinction: the SDK communicates with Copilot CLI, which has a separate license restricting modification of the CLI itself. The SDK’s MIT license does not make the entire agent runtime open source. The Foundry preview also supports Model Context Protocol (MCP) tools, reusable skills and agent definitions maintained alongside application code. These provide ways to customize and connect agents while the execution infrastructure remains managed by Foundry. sources - Foundry public preview announcement - Copilot SDK documentation and architecture - Copilot SDK MIT license - Copilot CLI license
How I built Browy, an AI agent that lives in your browser --- title: "How I built Browy, an AI agent that lives in your browser" published: true description: "Browy is an open-source AI agent that works inside the browser you're already signed into. Here's how the pieces fit together, and what broke along the way." tags: ai, webdev, opensource, architecture canonical_url: browyhq.github.io cover_image: browyhq.github.io --- When you send Browy its first message, Chrome puts up a bar saying that Browy started debugging this browser. No extension can hide it, Browy included. It appears because Browy drives your tabs through chrome.debugger, which lets an extension speak the Chrome DevTools Protocol, the same protocol your own DevTools use. I've grown to like that bar, because it describes Browy better than I usually manage to. Browy is an AI agent that lives in your browser, and I mean that literally. It works in the Chrome, Edge or Brave window you already have open, signed in to everything you're signed in to. You chat with it in the side panel, or type at it in a DevTools panel, and it reads pages, clicks, types, fills forms and runs JavaScript in your tabs while you watch. The models come through your own GitHub Copilot subscription. It's open source under Apache-2.0, and I've been building it on my own since April. This is the long version of how it works, with the bugs left in. What happens when you hit send Three programs on your computer take part, and they talk over pipes. The first is your browser, running Browy's extension: a side panel for chatting, a DevTools panel for people who'd rather type commands, and a settings page. None of them talk to the model. They talk to the extension's service worker, which keeps one connection open to the second program. The frontend architecture page walks through the extension file by file. That connection is Chrome's native messaging. Browy's installer registers a manifest under the name com.browy.host, and when the service worker calls chrome.runtime.connectNative('com.browy.host'), Chrome starts the host and wires the extension to its stdin and stdout. Each message is a four-byte length followed by JSON. The open port also keeps the service worker alive, which Manifest V3 would otherwise put to sleep. The host is a Node program, and it's where the agent lives. It wraps GitHub's Copilot SDK, which starts the third program, the Copilot CLI, in headless mode and talks JSON-RPC to it over another pair of pipes. The CLI makes the model calls on your Copilot subscription and stores each chat. Browy's code covers the browser half: the tools, and the picture of the page the model gets on every turn. The backend architecture page has the details. That picture is rebuilt each time you send a message. The host appends a <browser_context> block to your text, with the active tab's title and URL, the viewport, the scroll position and any text you've selected, followed by a snapshot of the page and the agent's last few tool calls. Your words go first, so the chat's summary in the history list shows what you asked. When the model calls a tool, the SDK runs a handler in the host. If the tool needs the page, the host sends a cdp.send message back up the native port, naming a DevTools Protocol method and its parameters. The service worker picks the tab, attaches chrome.debugger if it isn't attached yet, runs the command and sends the result back down. Network and console events travel the same route. The host coordinates, and every action happens in your browser. The DevTools panel is the same agent with a terminal in front: slash commands, a model picker, and a /js mode that runs each line in the inspected page without calling the model. Each panel works on the tab it inspects. The DevTools CLI page covers how the REPL is built. The page as a numbered list Ask a model to click something by CSS selector and it will happily invent one. Even a correct selector only says where an element sat at one moment, and a re-render can move it. So Browy hands the model a numbered list. One Runtime.evaluate call walks the page, shadow roots and same-origin iframes included, and keeps each element that's interactive, visible and within 2,000 pixels of the viewport. It sorts them in reading order, drops any that sit almost entirely inside another, stops at 150 and stamps each one with a data-browy-id attribute. The model sees something like this: [1]<a href=/expenses>My expenses</a> [4]<input type=text name=title placeholder="e.g. Client dinner" aria-name=Title /> [5]<input type=number name=amount aria-name="Amount (SGD)" /> [6]<input type=checkbox name=billable unchecked aria-name="Billable to client" /> [*7]<button>Attach receipt</button> [~12]<button type=submit>Submit for approval</button> A ~ marks an element that's off screen, which the click tools scroll into view first. A * marks one that wasn't in the previous snapshot, which is how the model notices that a dialog opened or a new part of the form appeared. The aria-name is what a screen reader would announce, usually the label you'd read yourself. For click_index(12), the host finds the stamped element and sends real mouse events to its center with Input.dispatchMouseEvent. Before and after, it records the URL, the title, a hash of the visible text, the number of open dialogs and the element's ARIA state. Half a second after the click it compares them and reports what changed, with flags like urlChanged, domChanged, modalAppeared and elementStateChanged. If nothing changed, the reply says so and suggests what might be in the way, such as an overlay or a page that's still rendering. I didn't invent this. The indexed snapshot is modeled on browser-use's DOM service and serializer, down to the rule for dropping nested elements, and I'm grateful to that team for working it out in the open. Turning tools off Browy has 54 browser tools, from click_index and fill_form to get_network_requests and replay_request. Most have a switch in Browy's settings. Turning one off removes it from two places, the tool definitions sent to the model and the SDK's availableTools allowlist, so the model never learns it exists. Two of them run code the model writes. evaluate_js runs JavaScript in the page, for the jobs the indexed tools can't express, and it's on. run_script runs Node.js on your computer, so it's off until you switch it on. It was on by default until version 0.1.10, which was the wrong default for a tool that reaches your machine. The Copilot CLI also brings tools of its own, like bash and web_fetch, which act on your computer instead of the tab. Browy keeps those out of the allowlist until you switch them on, one at a time, the same as run_script. This matters because Browy reads pages written by strangers while it's signed in as you. Any page can carry text aimed at the model, and Browy's security model assumes some pages will. The switches decide what a page like that could talk the agent into. Why your own browser Plenty of agents start a fresh browser, often on a server, with an empty profile that isn't signed in to anything. For public pages at scale, that's the right design. Most of my repetitive work happens on pages that only exist because I'm signed in, though: the pull request I'm reviewing, my Gmail filters, an expense claim, a journal's submission portal. A fresh browser sent to any of them lands on a login screen. I wrote about the form-filling side of this in an earlier post. Browy sidesteps that by staying in your browser. Ask about the pull request in front of you, and it reads the pull request in front of you, in the tab where you're already signed in. Your logins stay where you made them, in your browser profile, next to your cookies and your password manager. When a page wants a password or a 2FA code, the agent is meant to call await_user, which pauses and hands the job to you. That tool's own description tells the model to use it instead of typing into password fields. Your chat history is stored on your machine too, under ~/.copilot/. What leaves it is what the model needs to see: your messages and the page content the agent reads, sent through your Copilot subscription under GitHub's terms, the same way the Copilot CLI sends them from a terminal. The trade is scale. Browy works in one browser on one machine, and only when you ask. It won't run a hundred jobs overnight, and I'm fine with that. The second install, and other costs Browy needs two programs that live outside the browser, Node for the agent and the Copilot CLI for the model. An extension can't install those, and native messaging only reaches a program that's already registered on the machine. So Browy installs in two steps: the extension from the Chrome Web Store, then a one-line command in PowerShell or a terminal. The side panel shows the command for your system, with a copy button, and picks up the host by itself once it's registered. In my testing that took 3.6 seconds. The install guide has both commands. Having two halves also means two update schedules: the Web Store updates the extension, and you update the host, which the side panel prompts you to do when it falls behind. Then there's size. The Windows download is about 140 MB, most of it the Copilot CLI. In August I cut a 106 MB native binary out of every download, because the SDK ran the CLI as a Node script and nothing ever executed the binary. In October the CLI itself moved into that same package as a native executable. Switching to it cut the CLI's startup on Windows from 5.8 seconds to 0.6, and pushed the Windows download from about 80 MB back up to about 140. You download Browy once and wait for it to start far more often, so I'll take that trade. Browy is also Chromium only: Chrome, Edge and Brave. All of the above depends on chrome.debugger, and Firefox and Safari don't give extensions an equivalent. And there's the debugging bar from the top of this post. It stays up while Browy holds a tab, and clicking Cancel on it detaches the agent. The system prompt that never arrived In October I noticed that Browy still bundled version 1.0.36 of the Copilot CLI, while the current release was 1.0.95, the native binary from the last section. Updating meant checking each SDK call Browy makes against the new CLI, and that turned up a bigger problem. The SDK builds its session.create request from named fields, and the system prompt goes in one called systemMessage. Browy had been passing its prompt as systemPrompt since the first commit. Nothing complained. JavaScript doesn't mind an extra key on an options object, and the SDK never looked for that one. For five and a half months, every Browy chat ran on the Copilot CLI's default instructions plus Browy's tool list. The instructions that tell the model it's Browy, how to use the indexed tools and when to hand a sign-in back to you never reached it. The SDK's TypeScript types would have caught it, since SessionConfig has a systemMessage field and no systemPrompt. But Browy loads the SDK with require() and holds the client as any, so the compiler never compared the two. The part that stings is a comment about thirty lines higher in the same function, shipped in the very first release. It notes that an earlier includeDefaultTools: false flag had never been a real SDK option either. I had caught this exact kind of bug once and written it down, and the second one sat thirty lines below my note. I suspect Browy mostly behaved anyway because the tool descriptions did reach the model, and a lot of the guidance lives there. The fix was one line, systemMessage: { content: SYSTEM_PROMPT }, with a catch. The prompt I'd written in April told the model to fill a form and then submit it, and once the prompt started arriving, that rule would have taken effect for the first time. So the same release rewrote it. Browy now submits only when you ask it to submit, send, post or buy, and otherwise stops and tells you the form is ready to check. Asked its name, it finally says "I'm Browy." The chats that were on disk all along In May I removed my development copy of the extension to test a clean install, the way a new user would get it. When I opened the new copy, my older chats were missing from the history list. Uninstalling an extension wipes its storage, so I assumed they were gone. They weren't. The Copilot SDK had written every turn of every chat to its session store under ~/.copilot/, on my own disk, and the host already knew how to list and read those sessions. It just didn't recognize the old ones as Browy's. It looked for session ids starting with sp- (side panel) or dt- (DevTools), and my older chats predated that scheme. Version 0.1.4 recognizes a Browy session by any of three marks: the id prefix, the working directory every Browy session is created with, or the <browser_context> block Browy adds to messages. I widened the rule for listing chats but left the narrow one in charge of automatic cleanup, so nothing of uncertain origin could be deleted by accident. Chats looked lost once more in August, when "No past chats yet" turned out to be a four-second timeout on a host that needed between 7.7 and 40 seconds to answer. Both times, the chats were fine. That's an upside of a local agent I hadn't planned for: when the interface said my chats were gone, I could open the folder and check. The form it said it had filled On every turn, Browy shows the model its own last five tool calls in a <recent_actions> block, with each tool's arguments, whether it worked, how long it took and the start of its result. That's how the model checks whether its last click did anything. The same idea shows up in browser-use and in research agents like ReAct and SeeAct. The list lived on the host's agent object, and one agent serves every chat. So a new chat's first message arrived carrying the previous chat's last five calls. I found out on October 13, while filming the demo video that's now on Browy's homepage. In a brand-new chat, I asked Browy to fill the same form it had just filled in another chat. It replied that the form was filled. It hadn't called a single tool. The model wasn't making things up, exactly. It was reading. Its context showed fill_form coming back ok moments earlier, in what it had every reason to think was its own chat, and it believed the record. The fix keys the list by chat, in a small module of its own, and a new test fails if a fresh chat ever starts with another chat's tool calls again. Version 0.1.9 shipped the same day. The demo video came out of that same filming session. It runs 65 seconds in real time: one sentence typed into the side panel, a paper open in one tab, and a 26-field paper submission form in the other. By the end every field is filled, and the submit button is still waiting for me. --- - All posts Index of every post on the Browy blog. - Architecture: native host How the host wraps the Copilot SDK and routes tool calls back to the extension.
polyglot interactive and Microsoft agent sdk Hello, my use project is somewhat complex for intermediate developer, since I want to develop a monolithic platform with web-GUI. the idea here is to lunch a normal .net app which have csnake runtime . alongside with embedded notebook server for relp . AND github/copilot-sdk hosted locally, preferably with onnx runtime inference, (BYOK) the idea here is to enable user to annotate scripts . I know this is NOT how things work as there' many caveats and alternative way of doing it e.g. IPC or gRPC or file based scripting instead of old school FFI with pinvoke . Also csnake has different usecase ( interactive Jupiter notebook is not poyglot in a real sense but it is different kernels came together) but the idea here is to make .net as the backend & entry point while middleware / frontend with AG-UI Protocol which should integrate A2A,ACP etc.. users can utilize the sdk capability to annotate tools from the different package mangers ... thus I also need to integrate MISE MCP for validation .. what do you think ?
Copilot remembered the bookings. Why couldn't it resume the workflow? --- title: "Copilot remembered the bookings. Why couldn't it resume the workflow?" published: true tags: githubcopilot, ai, architecture, azure canonical_url: tamirdresher.com --- It started with a simple piece of curiosity: when I ask GitHub Copilot to create a dynamic workflow, how does it actually work behind the scenes? You type a plain-English sentence — "build me a workflow that books a flight, a hotel, and a rental car" — and a moment later, Copilot has registered an executable multi-agent workflow in your session. Very cute. Very magical. Very "look, Mom, the agents planned my vacation." Except I don't like magic in my runtimes. I wanted to know what was actually happening under the hood between my prompt and that running workflow. How does the model even know the workflow API? Is it baked into the model weights? Stuffed into a giant hidden system prompt? Loaded from a secret skill? Where does the generated code live on disk, and what actually executes it? So I did what I always do when a feature feels like a black box: I started pulling threads. I traced the tool schemas through the CLI binary, inspected the session logs, and opened Procmon to watch which files the process actually touched on disk. And of course, once I saw how the runtime builds and journals a workflow, the distributed-systems part of my brain (if you read my earlier post on Azure Durable Task Scheduler, you already know where this is going) couldn't resist asking the next question: what happens if I kill the process right after the hotel is booked, before the rental car agent finishes? Stick with me. What started as a peek behind the curtain turned into a great lesson in the difference between having a log of what happened and having a durable workflow you can actually resume. - * * Peeking Behind the Curtain: How Copilot Learns to Write a Workflow When you ask Copilot to create a dynamic workflow, there's no magic Markdown compiler turning your English into a state machine. It's a very practical, three-step handshake between the native CLI runtime and the bundled Copilot SDK. First, the native runtime exposes the workflow tools (dynamic_workflows_manage and run_dynamic_workflow) to the model through standard tool descriptions and schemas. If you trace the strings in the CLI (1.0.94-3 in my initial trace), they lead straight through the JavaScript bridge into the bundled prebuilds\win32-x64\runtime.node. And right inside the tool description for dynamic_workflows_manage, the runtime leaves the model a breadcrumb: call operation: "guide" before authoring any workflow code. So that's step one. The model calls dynamic_workflows_manage({ operation: "guide" }), and the runtime hands back the local paths to the bundled SDK documentation: %LOCALAPPDATA%\Programs\GitHub Copilot\copilot-sdk\docs\workflows.md %LOCALAPPDATA%\Programs\GitHub Copilot\copilot-sdk\index.d.ts (Depending on how you installed Copilot CLI, that path might live under %LOCALAPPDATA%\copilot\pkg\win32-x64\<version>\copilot-sdk\docs\workflows.md instead — which is why the guide operation returns the exact path for your active install rather than hardcoding it.) Step two is refreshingly low-tech: the model simply calls view on that workflows.md path and reads the manual. Literally. The SDK workflow guide enters the context window as a normal tool result, teaching the model the exact shape of defineWorkflow, ctx.step, ctx.agent, and ctx.parallel. Step three is turning that knowledge into a runnable extension. In the author flow, the model calls dynamic_workflows_manage({ operation: "author", meta, run }), passing the workflow metadata and the JavaScript run function body. The host validates it, wraps it in a small ES module scaffold under the session's extensions\<name>\extension.mjs folder, drops an .authored-workflow marker next to it, and reloads the extension into the session: import { defineWorkflow, joinSession } from "@github/copilot-sdk/extension"; const workflow = defineWorkflow({ /* generated meta and run omitted */ }); await joinSession({ workflows: [workflow] }); Notice the clean division of labor: the model writes the meta declaration and the run body; the host writes the import and joinSession boilerplate; and @github/copilot-sdk/extension resolves to the bundled SDK inside the extension host. Creating and registering the workflow doesn't run it — execution is a separate step via run_dynamic_workflow. Watching It Happen Live in the CLI Reading traces is one thing; watching it happen live is much more fun. I spun up a fresh Copilot CLI session (running 1.0.99-2 through cli-tunnel) and asked it to build a Seattle trip-planning workflow — register only, don't execute yet. !Actual live Copilot CLI input showing the Seattle workflow creation request before submission, with private fields masked. Asking Copilot CLI to create and register the Seattle trip-planning workflow. Right on cue, the first thing Copilot did was ask permission to read the bundled workflows.md guide from disk: !Actual Copilot CLI permission prompt for bundled workflows.md, with private paths masked. Copilot CLI asking permission to read the bundled copilot-sdk/docs/workflows.md guide before writing a single line of workflow code. In this newer CLI version (1.0.99-2), instead of the single-shot author call, Copilot used the extension lifecycle directly — guide → scaffold → edit → reload → inspect — and registered seattle-trip-planning with five ctx.step operations (three parallel simulated bookings, a review step, and a guarded confirmation) without executing it: !Actual Copilot CLI registration response for seattle-trip-planning, explicitly not run, with private identifiers masked. The workflow is scaffolded, reloaded, and verified in the registry — ready to run, with zero bookings made yet. Because I like to verify what happened behind the curtain, I checked the creation turn from two angles. First, I queried the session's recorded tool execution log to see the exact sequence of tool calls (guide returning the path → view reading workflows.md → scaffolding the extension → reading and patching extension.mjs): !Genuine live Copilot CLI display of a later read-only audit's verbatim stdout for selected creation tool records, with private command-path details masked. Inspecting the session's tool log: guide returns the SDK doc path, view reads workflows.md, and the extension scaffold gets patched in place. And second, because nothing beats OS-level ground truth, I opened Sysinternals Process Monitor (Procmon) and watched the process touch copilot-sdk\docs\workflows.md on disk: !Actual Procmon window showing later installed workflows.md guide accesses, with private path segments masked and limited visible columns. Procmon catching the file system accesses to the installed copilot-sdk\docs\workflows.md guide on disk. When you run that workflow on the happy path — say, a simulated trip from SFO to Seattle for November 10–14, 2026 — it flies through in one shot and prints a clean booking table: !Actual Copilot CLI completion summary for three valid simulated Seattle trip bookings, with private identifiers masked. The happy path: all three simulated bookings complete and the trip confirms cleanly on the first attempt. - * * What Copilot Actually Writes When You Don't Coach It Before we break things, it's worth looking at the code Copilot generated when I gave it an open-ended prompt in a fresh desktop session: > Create a Copilot dynamic workflow that plans a trip: given a destination, book a flight, a hotel and a rental car, review booking results, then confirm. Create/register only, do not execute or make reservations. I deliberately didn't mention Durable Task Scheduler, idempotency keys, or replay boundaries. I wanted to see what "natural" Copilot workflow code looks like out of the box. To its credit, it didn't write a naive "call three things and print success" script. It set up three parallel booking agents, followed by a fourth review agent, followed by a guarded confirmation step: const services = ["flight", "hotel", "rental car"]; const results = await ctx.parallel(services.map((service) => () => ctx.agent( `Book the ${service} for a trip to ${JSON.stringify(tripDestination)}. ` + "Use available booking tools, actual service responses, and only travel details, constraints, and authorization already supplied by the user. " + "If essential details, authorization, or booking tools are missing, return needs_input and explain what is needed. " + "Return booked only after the travel service confirms the reservation, with its actual confirmation number. " + "If the booking fails, return failed with the reason. Never invent a reservation or confirmation number.", { label: `Book ${service}`, schema: bookingSchema }, ))); It even added structured schema checks (booked, needs_input, failed), verified that every booking returned a non-empty confirmation number, and had the review agent validate consistency before allowing the final ctx.step("confirm-trip", ...) to confirm the itinerary: if (!allBooked || !review.approved || review.issues.length > 0) { return { status: "not_confirmed", destination: tripDestination, bookings, review }; } return await ctx.step("confirm-trip", () => ({ status: "confirmed", destination: tripDestination, bookings, review, })); As application logic goes, that's genuinely thoughtful code. As a durable distributed workflow, though, look closely at what's missing. There are no provider idempotency keys. There's no external orchestration state mapping a logical trip to a resumable run. And wrapping the final return object in ctx.step("confirm-trip", ...) checkpoints the final summary — it doesn't magically wrap the external tool calls happening inside those three parallel ctx.agent(...) calls in durable, idempotent boundaries. Which brings us to the fun part: pulling the plug. - * * Pulling the Plug Between "Booked" and "Finished" Let's run the crash test. (And before anyone worries about my corporate Amex: all bookings in these tests hit local fake booking providers. The Copilot workflows and agents were real; the airlines were not.) I ran that exact generated trip workflow on the default local executor (1.0.95-3) and engineered a very specific crash point: 1. Flight agent calls the flight tool, gets a simulated reservation, and finishes (flight = 1). 2. Hotel agent calls the hotel tool, gets a simulated reservation, and finishes (hotel = 1). 3. Car agent reaches the rental car tool — and I hold the request right before it creates the reservation (car = 0). 4. While the workflow is mid-flight, I kill the CLI, extension host, and provider processes. Then I reopened the exact same Copilot session. Here's the part that surprised me at first. This wasn't a "the process died and wiped its memory" failure. The local journal had saved the completed flight and hotel records. The fake provider receipts were sitting right there on disk. Copilot remembered the bookings. So I asked it to resume the interrupted run. And the runtime came back with a flat refusal: > was interrupted before it settled durably and cannot be resumed The run was marked error (reason: interrupted, canResume: false). Even worse, the old rental car agent row was still stuck reporting running in storage after the process had died, leaving a phantom live-agent count of 1. How Copilot Actually Maintains Its Native Ledger (and Why It Still Wasn't Enough) To understand why Copilot remembered the bookings but still refused to continue, I looked at how the SDK and native CLI actually maintain workflow state under the hood (anchored in nodejs/src/session.ts and src\runtime\src\workflows\store.rs in the native runtime). Copilot's default local workflow runner is not stateless. When a workflow starts, the native runtime mints a runId and an active executionToken for that attempt, and every method on ctx talks back to the native session store over JSON-RPC: - ctx.step(key, producer) is a read-through, write-through memoization cache backed by the native ledger (session.workflow.journal.get and session.workflow.journal.put). - ctx.agent(prompt, options) calls session.workflow.agent, which spawns the sub-agent and records a full lifecycle row (agentId, phaseId, status, resolvedModel, startedAt, completedAt, activeMs, plus consumed sub-agent and token accounting on the run envelope). - ctx.log and ctx.phase batch ordered progress lines through WorkflowProgressBuffer and flush them via session.workflow.log. Look at the ctx.step implementation in nodejs/src/session.ts — it's super clean: const cached = await awaitWorkflowOperation( () => self.rpc.workflow.journal.get({ runId: params.runId, executionToken: params.executionToken, key, }), controller.signal ); if (cached.hit) { return cached.resultJson; } const result = await producer(); await awaitWorkflowOperation( () => self.rpc.workflow.journal.put({ runId: params.runId, executionToken: params.executionToken, key, resultJson: result, }), controller.signal ); return result; So why couldn't it resume if journal.put and workflow.agent had already written the completed outputs to disk? Because in the native store, having journal entries on disk is not what makes a run resumable. A run is only marked canResume: true when the attempt settles cleanly at a controlled checkpoint (for example, pauseAtCheckpoint). When you kill the process mid-flight while an executionToken is still active, the runtime sees an ungracefully severed attempt. On rejoin, it marks the run envelope as status: "error", reason: "interrupted", and canResume: false — and any call to session.workflow.resume (resumeFromRunId) gets rejected before it ever touches the step journal. To verify that directly against journal.get / journal.put, I reproduced the exact same crash boundary in the 1.0.99-2 CLI with a deterministic step-only version (seattle-trip-interruption-demo). All three parallel bookings and the review step finished and were written to the native step journal via session.workflow.journal.put, and the workflow paused at a file gate right before final confirmation (WAITING_FOR_OPERATOR_RELEASE): !Actual Copilot CLI running status and operator-release waiting log for the separate controlled-interruption demo, with private identifiers masked. Pausing the workflow right before final confirmation — three bookings and the review step are already completed and saved in the local journal. I killed the CLI and its workers, reopened the same conversation, and called run_dynamic_workflow with resumeFromRunId for that run. Same result: !Actual Copilot CLI same-run resume refusal in the reopened controlled-demo conversation, with private identifiers masked. Reopening the session and attempting resumeFromRunId: the journal still has all four completed step results, but the runtime refuses to resume the interrupted run. All four completed step outputs were still sitting in the database byte-for-byte intact. The ledger remembered the data, but the crashed attempt lost execution authority. That's the distinction that's so easy to miss when you only test the happy path: Remembering completed work and being able to resume a workflow are two completely different capabilities. A ledger tells you what happened before the crash. A durable orchestrator knows how to replay that history, re-establish execution authority, and pick up at the exact next step without re-running completed work. - * * Wiring in Durable Task Scheduler (and Where the Native Boundary Pushes Back) This is the exact problem space Azure Durable Task Scheduler (DTS) was built for. So I built an experimental fork of copilot-sdk to see what it takes to plug DTS into joinSession. I added an optional WorkflowExecutor seam so an extension can bring its own durable execution engine while defaulting to normal local behavior if omitted: export interface WorkflowExecutor { execute( definition: WorkflowDefinition, invocation: WorkflowInvocation, host: WorkflowExecutionHost, signal: AbortSignal ): Promise<{ result?: JsonValue }>; } Under the hood, the architecture splits cleanly into three pieces: - The client schedules or attaches to a deterministic DTS orchestration instance. - The worker pulls work items and runs the JavaScript workflow code. - The scheduler (DTS) persists the orchestration history across process crashes. When a worker restarts after a crash, the replay adapter (workflow-replay.ts) walks the workflow code against the recorded JSON history in DTS. Every Agent, Step, and Progress call becomes a tracked activity. If DTS already has a recorded completion for step 1, replay returns the cached JSON immediately without re-invoking the agent or step, and advances until it hits the first unseen operation. How Recovery Actually Works: The Carrier Run Pattern Here's the catch, though: WorkflowExecutor lives in the JavaScript SDK host, while resumeFromRunId is gated by the native CLI runtime. Teaching the JavaScript layer about DTS doesn't magically rewrite the native binary's rule that an interrupted native run ID is non_resumable. So how do you recover an interrupted DTS-backed workflow when the native CLI refuses resumeFromRunId? You use a new native run as a carrier for the existing DTS orchestration instance. I tested this end-to-end using the official changed-file code review workflow (review-changed.mjs) running against a local DTS emulator. The workflow reviews two files in sequence: a cart-total calculation (which has a bug: reduce missing its initial 0 accumulator) and a discount-price function (which accidentally subtracts a percentage instead of multiplying). 1. On Run 1, the first review agent finished analyzing the cart fixture (492 bytes of structured review output) and DTS checkpointed the completion. 2. Before the second review agent could finish, I killed the process. 3. After reopening, instead of asking the native CLI to resume the dead native run ID, I launched a fresh native carrier run configured via workflow-dts.ts to attach to the original DTS instance ID (verifying that the workflow scope, arguments, and code hash matched). It worked like a charm. The carrier run attached to the existing DTS orchestration, replayed past the first step by reusing the exact 492-byte cart review from DTS history, and spawned only one new agent to finish the discount-file review. Across the crash and recovery, only two workflow agents ever ran — one per file. Is it seamless UI magic yet? Not quite. Because the carrier is a new native run, the CLI UI doesn't automatically rehydrate the old Review phase progress bars from the dead run, and you still have to manage your logical run mapping and budgets across attempts (plus make sure your external tools carry idempotency keys if a worker dies right after an API call succeeds). Honest engineering trade-offs — but the core replay mechanics are rock solid. - * * Where This Leaves Us I started this whole rabbit hole just wanting to know how Copilot turns a plain-English sentence into a runnable workflow. What I love about looking under the hood here is how pragmatic the design actually is. There's no mystery compiler — just a clean guide → view → extension.mjs loop that teaches the model the SDK on demand and registers normal JavaScript into the session. And the local runtime already does the work of journaling step outputs, agent runs, and progress lines as it goes. Where things get interesting is the gap between keeping a ledger and recovering a live workflow. When a process dies mid-flight, having the previous step results sitting on disk isn't enough if the runtime can't safely re-establish execution authority and replay past completed work. Plugging a WorkflowExecutor seam into copilot-sdk showed just how naturally Azure Durable Task Scheduler fits that gap: you keep the exact same defineWorkflow developer experience, let Copilot author the workflow the way it already wants to, and let DTS handle the replay when reality inevitably pulls the plug.
@okazuki ですよね。 jsonで渡す方法(TypedDict)から名前付き引数に変わっていました。 どうやらGAのときに引数が変わっていたようです。 github.com/github/copilot-sdk/blob/main/CHANGELOG…