v1.0.0
⚠️ Work in Progress — This guide describes an API and SDK that are actively under development. Interfaces, response shapes, and behaviors may change before the final release.
This guide walks you through building a custom engine — a program that receives a coding task from the Copilot platform, runs an agentic loop against a repository, and streams progress back to users in real time.
An engine is a program that the platform executes when a user triggers a coding task — such as assigning Copilot to an issue, requesting changes on a PR, or invoking a task via the API. The platform creates a job, launches your engine in a secure runner environment, and your engine does the work.
You can write an engine in any language. The initial SDK we provide is TypeScript, but engines are not limited to TypeScript. If you're using a different language, the SDK source (once open-sourced) documents the HTTP APIs you'd call directly.
sequenceDiagram
participant User
participant Platform
participant Engine as Your Engine
participant Repo as GitHub Repository
User->>Platform: Assigns issue / comments on PR / triggers task
Platform->>Platform: Creates job
Platform->>Engine: Launches engine with environment variables
Engine->>Platform: GET /jobs/{jobId} — fetch job details
Platform-->>Engine: Problem statement, repo metadata, action type, commit attribution
Engine->>Repo: Clone repository
loop Each turn
Engine->>Platform: LLM inference via inference API
Platform-->>Engine: Model response
Engine->>Platform: POST /jobs/{jobId}/progress — stream events
end
Engine->>Repo: Commit and push changes
- Fetch job details from the platform API (problem statement, repo metadata, action type)
- Clone the repository using job metadata +
GITHUB_GIT_TOKEN - Run an agentic loop using your inference client and
GITHUB_INFERENCE_TOKEN - Stream progress events so users can follow the agent's work in the GitHub UI
- Commit and push the resulting changes
- A runtime for your language of choice (the example uses Node.js 20+)
- The Engine SDK (TypeScript:
@github/copilot-engine-sdk) — optional but recommended; handles platform communication and git operations
A minimal engine:
my-engine/
├── engine.yaml # Engine definition (required)
├── src/
│ └── index.ts # Entry point (or your language equivalent)
├── package.json
└── tsconfig.json
Create an engine.yaml file at the root of your repository. This tells the platform how to execute your engine. The entrypoint is the fully qualified command that will be run — there is no implicit runtime setup.
name: 'My Custom Engine'
description: 'A custom coding agent engine'
author: 'Your Name'
# The fully qualified command to run the engine.
# The platform executes this command directly — no implicit runtime setup.
entrypoint: 'node --enable-source-maps dist/index.js'Note: This is not a GitHub Action. The platform reads
entrypointfromengine.yamland runs it directly. All paths in the entrypoint are resolved relative to the engine's root directory.
The platform injects these environment variables into the engine process at runtime:
| Variable | Required | Description |
|---|---|---|
GITHUB_JOB_ID |
Yes | Unique job identifier. Used to fetch job details and report progress. |
GITHUB_PLATFORM_API_TOKEN |
Yes | Token for authenticating with the platform API. |
GITHUB_PLATFORM_API_URL |
Yes | Base URL for all platform API calls. |
GITHUB_JOB_NONCE |
No | Nonce for request validation (included in API headers when present). |
GITHUB_INFERENCE_TOKEN |
Yes | Token used by your inference client / SDK for model calls. |
GITHUB_INFERENCE_URL |
Yes | Base URL for the inference API (e.g. Copilot API). Use this along with GITHUB_INFERENCE_TOKEN to make LLM inference calls. |
GITHUB_GIT_TOKEN |
Yes | Token used for authenticated git clone / git push. |
The first thing your engine should do is fetch the job details. This call returns task metadata (problem statement, repository info, action type, commit attribution, and optional custom instructions).
import { PlatformClient } from "@github/copilot-engine-sdk";
const platform = new PlatformClient({
apiUrl: process.env.GITHUB_PLATFORM_API_URL!,
jobId: process.env.GITHUB_JOB_ID!,
token: process.env.GITHUB_PLATFORM_API_TOKEN!,
nonce: process.env.GITHUB_JOB_NONCE,
});
const job = await platform.fetchJobDetails();If you're not using the TypeScript SDK, this is a standard HTTP call:
GET {GITHUB_PLATFORM_API_URL}/jobs/{GITHUB_JOB_ID}
Headers:
Authorization: Bearer {GITHUB_PLATFORM_API_TOKEN}
X-GitHub-Job-Nonce: {GITHUB_JOB_NONCE} # if nonce is provided
Note: This response shape is not finalized and may change. Treat field names and structure as preliminary.
{
"ID": "job-123",
"problem_statement": {
"content": "The login page throws a 500 error when..."
},
"action": "fix",
"organization_custom_instructions": "Always use TypeScript strict mode...",
"repository": "octocat/my-repo",
"server_url": "https://github.com",
"branch_name": "copilot/fix-123",
"commit_login": "copilot-bot",
"commit_email": "copilot-bot@users.noreply.github.com",
"mcp_proxy_url": "http://127.0.0.1:2301"
}| Field | Description |
|---|---|
problem_statement.content |
The issue body, PR comment, or task description the agent should address. |
action |
The type of task: fix, fix-pr-comment, or task. See Handle Action Types. |
organization_custom_instructions |
Optional instructions from the repository's organization to include in your system message. |
repository |
Target repository in owner/repo format. |
server_url |
GitHub server URL (e.g., https://github.com). |
branch_name |
Branch to checkout or create. |
commit_login |
Git author name for commits. |
commit_email |
Git author email for commits. |
mcp_proxy_url |
Optional URL of the MCP proxy server. When present, use it to discover user-provided MCP servers. See User-Provided MCP Servers. |
Use GITHUB_INFERENCE_TOKEN for model calls and GITHUB_GIT_TOKEN for git operations; those are bootstrap action inputs, not job response fields.
The job response may also include additional fields (for example, custom agent configuration) that are not shown in the minimal payload above.
Using repository metadata from the job response and GITHUB_GIT_TOKEN, clone the target repository.
The SDK's cloneRepo function handles authentication, branch checkout, and git author configuration:
import { cloneRepo } from "@github/copilot-engine-sdk";
const repoLocation = cloneRepo({
serverUrl: job.server_url,
repository: job.repository,
gitToken: process.env.GITHUB_GIT_TOKEN!,
branchName: job.branch_name,
commitLogin: job.commit_login,
commitEmail: job.commit_email,
});If you're not using the SDK, the clone process is:
- Build the authenticated URL:
https://x-access-token:{GITHUB_GIT_TOKEN}@github.com/{repository}.git - Try to clone the specific branch:
git clone -b {branch_name} --single-branch --depth 2 {url} {dest} - If the branch doesn't exist on the remote, clone the default branch and create a new local branch:
git checkout -b {branch_name} - Configure the git author:
git config user.name "{commit_login}" git config user.email "{commit_email}"
A branch_name is always provided in the job response. For fix and task actions the branch is typically new (your engine creates it), while for fix-pr-comment the branch already exists on the remote.
The action field in the job response tells your engine what type of task it's handling. Your engine should adapt its behavior — particularly the system message — based on this value.
| Action | Trigger | What to Do |
|---|---|---|
fix |
Issue assigned to Copilot | Solve the problem described in the issue. Create a new branch, make changes, push. |
fix-pr-comment |
Comment or review on a PR | Address the feedback on an existing PR. The branch already exists; make changes and push to it. |
task |
API call | Complete a generic task. May or may not involve code changes. |
fix: The problem statement is an issue body. Your agent should analyze the codebase, implement a solution, and push to a new branch. Tailor your system message for problem-solving.fix-pr-comment: The problem statement includes the PR context and reviewer comments. Your agent should address the specific feedback — which may mean making code changes, reverting changes, or providing an explanation. The branch already exists. Tailor your system message for responding to feedback.task: The problem statement is a free-form task description. Handle it flexibly.
// Example: branching system message based on action
switch (job.action) {
case "fix":
systemMessage = "You are a coding assistant. Solve the problem with minimal changes...";
break;
case "fix-pr-comment":
systemMessage = "You are a coding assistant. Address the PR feedback...";
break;
case "task":
systemMessage = "You are a coding assistant. Complete the described task...";
break;
}As your agentic loop runs, send progress events to the platform so users can follow the agent's work in the GitHub UI.
import { PlatformClient } from "@github/copilot-engine-sdk";
const platform = new PlatformClient({
apiUrl: process.env.GITHUB_PLATFORM_API_URL,
jobId: process.env.GITHUB_JOB_ID,
token: process.env.GITHUB_PLATFORM_API_TOKEN,
nonce: process.env.GITHUB_JOB_NONCE,
});
// When the LLM produces a message (with optional tool calls)
await platform.sendAssistantMessage({
turn: 0,
callId: "unique-id",
content: "I'll start by examining the code...",
toolCalls: [
{ id: "tc-1", name: "read_file", arguments: '{"path": "src/main.ts"}' },
],
});
// When a tool finishes executing
await platform.sendToolMessage({
turn: 0,
callId: "unique-id",
toolCallId: "tc-1",
toolName: "read_file",
content: "// file contents here...",
});
// When a tool execution completes (telemetry)
await platform.sendToolExecution({
turn: 0,
callId: "unique-id",
toolCallId: "tc-1",
toolName: "read_file",
result: "// file contents...",
success: true,
});
// When an error occurs
await platform.sendModelCallFailure({
turn: 0,
callId: "unique-id",
error: "Rate limit exceeded",
});For each LLM turn, the typical event sequence is:
sequenceDiagram
participant Engine
participant Inference as Inference API
participant Platform
Engine->>Inference: Send prompt + tool results
Inference-->>Engine: Response with text + tool calls
Engine->>Platform: sendAssistantMessage (text + tool calls)
loop For each tool call
Engine->>Engine: Execute tool
Engine->>Platform: sendToolMessage (tool result)
Engine->>Platform: sendToolExecution (telemetry)
end
Note over Engine,Platform: Repeat for each turn until LLM finishes
If you're not using the SDK, progress events are sent via:
POST {GITHUB_PLATFORM_API_URL}/jobs/{GITHUB_JOB_ID}/progress
Headers:
Authorization: Bearer {GITHUB_PLATFORM_API_TOKEN}
Content-Type: application/json
X-GitHub-Job-Nonce: {GITHUB_JOB_NONCE} # if nonce is provided
Body:
{
"namespace": "sessions-v2",
"kind": "log",
"version": 0,
"content": "<JSON-encoded event>"
}
The sessions-v2 namespace tells the platform to accept clean, semantic event payloads. The platform translates them into the wire format the UI expects. If you're implementing progress reporting without the SDK, the event schemas are documented below.
Three special sessions-v2 kinds trigger side-effects beyond storing the event:
report_progress — Updates the PR title and/or description (incremental, during the loop):
{
"namespace": "sessions-v2",
"kind": "report_progress",
"version": 0,
"content": "{\"pr_title\":\"Fix null pointer in UserService\",\"pr_description\":\"Added null checks...\"}"
}comment_reply — Replies to a PR comment (for fix-pr-comment actions):
{
"namespace": "sessions-v2",
"kind": "comment_reply",
"version": 0,
"content": "{\"comment_id\":12345,\"message\":\"I've addressed the issue.\"}"
}pr_summary — Final PR title and description (sent once after the loop, see Step 8):
{
"namespace": "sessions-v2",
"kind": "pr_summary",
"version": 0,
"content": "{\"pr_title\":\"Fix null pointer in UserService\",\"pr_description\":\"Added null checks to prevent crashes when...\"}"
}All three kinds are stored as progress events first, then the side-effect runs. If the side-effect fails (e.g., the PR doesn't exist), the progress event is still stored and the HTTP response is still 201 Created.
interface PlatformClientConfig {
apiUrl: string; // Platform API base URL
jobId: string; // Job ID
token: string; // Platform API token
nonce?: string; // Job nonce
namespace?: string; // Event namespace (default: "sessions-v2")
}
class PlatformClient {
sendAssistantMessage(options): Promise<SendResult>;
sendToolMessage(options): Promise<SendResult>;
sendToolExecution(options): Promise<SendResult>;
sendModelCallFailure(options): Promise<SendResult>;
sendTruncation(options): Promise<SendResult>;
sendResponse(options): Promise<SendResult>;
sendReportProgress(options): Promise<SendResult>; // Update PR title/description
sendCommentReply(options): Promise<SendResult>; // Reply to a PR comment
sendProgress(event: Event): Promise<SendResult>; // Low-level: send any event
// Generic progress APIs — for session history and custom persistence (see Step 8)
sendRawProgress(payloads: ProgressPayload | ProgressPayload[]): Promise<SendResult>;
fetchProgress(options?: { namespace?: string; history?: boolean }): Promise<ProgressRecord[] | null>;
}Engines provide tools to the LLM via MCP (Model Context Protocol) servers. The Engine SDK includes a built-in MCP server with the report_progress tool (and optionally reply_to_comment for PR comment actions), which commits changes and reports progress to users.
// The SDK provides a pre-built MCP server
const mcpServerPath = require.resolve("@github/copilot-engine-sdk/mcp-server");
// Configure it when creating your LLM session
// For "fix" actions, provide report_progress
// For "fix-pr-comment" actions, also include reply_to_comment
const tools = action === "fix-pr-comment"
? ["report_progress", "reply_to_comment"]
: ["report_progress"];
const session = await llmClient.createSession({
mcpServers: {
"engine-tools": {
command: "node",
args: [mcpServerPath, repoLocation],
tools,
},
},
});This is the primary tool your agent uses to save and share its work. When called, it:
- Stages all changes (
git add .) - Commits with the provided message
- Pushes to the remote branch
- Sends a
report_progressevent to update the PR title and description
report_progress(
commitMessage: "Fix null pointer in user service",
prDescription: "## Plan\n- [x] Identify the bug\n- [x] Add null check\n- [ ] Add tests"
)
The platform receives the report_progress event and updates the associated pull request. It preserves auto-generated suffixes and tips in the PR body.
This tool is available when the action is fix-pr-comment. It replies to the PR comment that triggered the job:
reply_to_comment(
commentId: 12345,
message: "I've fixed the issue you mentioned. The null check has been added."
)
When called, it sends a comment_reply event to the platform, which posts a reply to the original comment with a truncated quote of the original text and removes the 👀 reaction.
Users can configure their own MCP servers (for example, code search, documentation tools, or database access) through the platform. These servers are started by the platform before your engine is launched and run outside the firewall so they have unrestricted network access.
The job response includes an mcp_proxy_url field when user MCP servers are configured. The proxy is an HTTP server that bridges your engine to the user's MCP servers. Your engine discovers available servers from the proxy and connects to them as HTTP MCP endpoints.
sequenceDiagram
participant Engine
participant Proxy as MCP Proxy (localhost:2301)
participant MCP as User MCP Server
Engine->>Proxy: GET /health
Proxy-->>Engine: 200 OK
Engine->>Proxy: GET /mcp/servers
Proxy-->>Engine: { servers: [{ name: "my-tools", proxyEndpoint: "/mcp/my-tools" }] }
Note over Engine: Build HTTP MCP configs
Engine->>Proxy: MCP protocol via /mcp/my-tools
Proxy->>MCP: Forward to actual server
MCP-->>Proxy: Tool results
Proxy-->>Engine: Tool results
import { discoverMCPServers } from "@github/copilot-engine-sdk";
// Discover user MCP servers from the proxy URL in job details
const userMCPServers = job.mcp_proxy_url
? await discoverMCPServers(job.mcp_proxy_url)
: {};
// Pass to createSession — they merge with your built-in tools
const session = await llmClient.createSession({
mcpServers: {
// Built-in engine tools (stdio)
"engine-tools": {
command: "node",
args: [mcpServerPath, repoLocation],
tools: ["report_progress"],
},
// User-provided servers (HTTP, via proxy)
...userMCPServers,
},
});If you're not using the SDK, the discovery is two HTTP calls:
- Health check:
GET {mcp_proxy_url}/health— returns 200 if proxy is available - List servers:
GET {mcp_proxy_url}/mcp/servers— returns{ "servers": [{ "name": "...", "proxyEndpoint": "..." }] }
For each discovered server, connect to {mcp_proxy_url}{proxyEndpoint} (using the proxyEndpoint from the response) as an HTTP MCP endpoint using the standard MCP protocol. If proxyEndpoint is an absolute URL, use it directly.
All tools from all servers are exposed. There is no per-server tool filtering.
If you're not using the SDK, connect to each configured MCP server as a standard MCP client, using the auth fields provided in its config. The MCP protocol specification is available at modelcontextprotocol.io.
You can also bundle your own MCP servers alongside the built-in and user-provided ones. Any tool your LLM supports as an MCP server will work.
After your agentic loop completes, run a final sweep to commit and push any remaining changes. The report_progress tool is called by the LLM during execution, but since that's non-deterministic, your engine should always do a final check.
import { finalizeChanges } from "@github/copilot-engine-sdk";
// After the agentic loop
finalizeChanges(repoLocation, "Finalize changes");# Check for uncommitted changes
git status --porcelain
# If there are changes, stage and commit
git add .
git commit -m "Finalize changes"
# Always push (catches any locally committed changes too)
git push --set-upstream origin HEADThis should be non-fatal — if there's nothing to commit or the push fails, log the outcome but don't fail the engine.
function cloneRepo(options: CloneRepoOptions): string;
function commitAndPush(repoLocation: string, commitMessage: string): CommitAndPushResult;
function finalizeChanges(repoLocation: string, commitMessage: string): void;
interface CommitAndPushResult {
success: boolean;
hadChanges: boolean;
message: string;
}For fix and fix-pr-comment actions, your engine should generate a final, polished PR title and description after the agentic loop completes. This replaces the incremental report_progress checklist with a proper summary that reads well on the pull request.
Skip this step for task actions — they may not produce a pull request.
After the agentic loop finishes, send a follow-up prompt to the LLM asking it to summarize the work as a PR title and description. The LLM has the full conversation context (all tool calls, code changes, reasoning), so it can produce an accurate summary. Then send the result as a pr_summary progress event.
// After the agentic loop completes (sendAndWait returned)
if (action !== "task") {
// Send a follow-up in the same session — the LLM retains full context
const response = await session.sendAndWait({
prompt: `Write a final PR title and description for the changes you made.
Respond with <pr_title>...</pr_title> and <pr_description>...</pr_description> XML tags.
Do NOT use any tools.`,
});
// Parse the XML tags from the response
const content = response?.data?.content ?? "";
const titleMatch = content.match(/<pr_title>\s*([\s\S]*?)\s*<\/pr_title>/);
const descMatch = content.match(/<pr_description>\s*([\s\S]*?)\s*<\/pr_description>/);
if (titleMatch && descMatch) {
await platform.sendRawProgress({
namespace: "sessions-v2",
kind: "pr_summary",
version: 0,
content: JSON.stringify({
pr_title: titleMatch[1].trim(),
pr_description: descMatch[1].trim(),
}),
});
}
}POST {GITHUB_PLATFORM_API_URL}/jobs/{GITHUB_JOB_ID}/progress
Headers:
Authorization: Bearer {GITHUB_PLATFORM_API_TOKEN}
Content-Type: application/json
X-GitHub-Job-Nonce: {GITHUB_JOB_NONCE} # if nonce is provided
Body:
{
"namespace": "sessions-v2",
"kind": "pr_summary",
"version": 0,
"content": "{\"pr_title\":\"Fix null pointer in UserService\",\"pr_description\":\"Added null checks to prevent crashes when...\"}"
}
The platform receives the pr_summary event and updates the associated pull request's title and description — the same behavior as report_progress, but semantically this is the final, polished version.
report_progress |
pr_summary |
|
|---|---|---|
| When | During the agentic loop (multiple times) | Once, after the loop completes |
| Content | Incremental checklist of completed/remaining work | Final, polished PR title and description |
| Who sends it | The LLM via the report_progress tool |
Your engine code, after prompting the LLM |
| For actions | All actions | fix and fix-pr-comment only |
When responding to PR feedback, the scope of changes may be minor (fixing linters, style tweaks). If the LLM determines the existing PR title and description are still accurate, it can skip the update. Design your prompt to allow this — for example, by accepting a NO_CONTENT sentinel in lieu of XML tags.
Engines run in a secure runner environment, which means your process may be restarted (container preemption, timeout retry, follow-up jobs on the same assignment). The progress API doubles as a persistence layer — you can save arbitrary session state as progress events and retrieve it on the next run to resume where you left off.
This works because:
- Progress events are stored durably by the platform
- The
history=truequery parameter returns events from previous jobs on the same assignment - You control the
namespaceandkindfields, so your history events don't collide with UI-facing events
sequenceDiagram
participant Engine as Engine (Run N)
participant Platform
participant Engine2 as Engine (Run N+1)
Note over Engine: Agentic loop completes
Engine->>Platform: POST /progress — save session state
Note over Platform: Events stored durably
Note over Engine2: New run starts
Engine2->>Platform: GET /progress?namespace=my-engine&history=true
Platform-->>Engine2: Events from run N
Engine2->>Engine2: Restore session state
Engine2->>Engine2: Resume agentic loop
Use sendRawProgress to save your session state under a custom namespace. The namespace can be any string — pick something unique to your engine to avoid collisions with UI events (sessions-v2) or other engines.
import { PlatformClient, ProgressPayload } from "@github/copilot-engine-sdk";
const platform = new PlatformClient({ apiUrl, jobId, token, nonce });
// Save a metadata record (e.g., session ID, checkpoint info)
await platform.sendRawProgress({
namespace: "my-engine",
kind: "session-meta",
version: 0,
content: JSON.stringify({ sessionId: "abc-123", model: "claude-sonnet-4.5" }),
});
// Save session state — could be anything: conversation log, file snapshots, etc.
await platform.sendRawProgress({
namespace: "my-engine",
kind: "session-state",
version: 0,
content: JSON.stringify({ messages: [...], checkpointId: "cp-42" }),
});For large state (e.g., hundreds of conversation turns), send multiple records in a single call. The platform supports batch creates — pass an array instead of a single object:
const records: ProgressPayload[] = lines.map(line => ({
namespace: "my-engine",
kind: "conversation-line",
version: 0,
content: line,
}));
// Send in batches of 100
for (let i = 0; i < records.length; i += 100) {
await platform.sendRawProgress(records.slice(i, i + 100));
}POST {GITHUB_PLATFORM_API_URL}/jobs/{GITHUB_JOB_ID}/progress
Headers:
Authorization: Bearer {GITHUB_PLATFORM_API_TOKEN}
Content-Type: application/json
X-GitHub-Job-Nonce: {GITHUB_JOB_NONCE} # if nonce is provided
Body (single):
{
"namespace": "my-engine",
"kind": "session-meta",
"version": 0,
"content": "{\"sessionId\":\"abc-123\"}"
}
Body (batch):
[
{ "namespace": "my-engine", "kind": "session-meta", "version": 0, "content": "..." },
{ "namespace": "my-engine", "kind": "session-state", "version": 0, "content": "..." }
]
On startup, fetch progress with history=true and your namespace to retrieve events from previous runs:
const records = await platform.fetchProgress({
namespace: "my-engine",
history: true,
});
if (records && records.length > 0) {
// Find your metadata
const meta = records.find(r => r.kind === "session-meta");
const stateRecords = records.filter(r => r.kind === "session-state");
// Restore your session from the retrieved records
if (meta) {
const { sessionId } = JSON.parse(meta.content);
// ... rebuild your session state
}
}GET {GITHUB_PLATFORM_API_URL}/jobs/{GITHUB_JOB_ID}/progress?namespace=my-engine&history=true
Headers:
Authorization: Bearer {GITHUB_PLATFORM_API_TOKEN}
X-GitHub-Job-Nonce: {GITHUB_JOB_NONCE} # if nonce is provided
Returns an array of ProgressRecord objects:
[
{
"id": "evt-1",
"namespace": "my-engine",
"kind": "session-meta",
"version": 0,
"content": "{\"sessionId\":\"abc-123\"}",
"created_at": 1706000000
},
{
"id": "evt-2",
"namespace": "my-engine",
"kind": "session-state",
"version": 0,
"content": "{\"messages\":[...]}",
"created_at": 1706000001
}
]Namespace isolation. Use a namespace unique to your engine. The sessions-v2 namespace is reserved for UI-facing events. The platform only dispatches side-effects (PR updates, comment replies) for sessions-v2 events — your custom namespace events are stored without side-effects.
Content is opaque. The content field is a JSON string that the platform stores but does not interpret. You can put anything in it — serialized conversation history, binary-encoded checkpoints (base64), file contents, or structured metadata. The format is entirely up to your engine.
Kind as a discriminator. Use different kind values to distinguish record types within your namespace (e.g., session-meta vs session-state vs conversation-line). This makes filtering and parsing straightforward on restore.
History spans jobs, not assignments. The history=true flag returns events from the previous job on the same assignment. If an assignment goes through multiple retries or follow-up jobs, each run can see what the prior run saved.
Batch size limits. The platform accepts up to 100 records per batch request. For larger payloads, chunk your records into batches of 100.
Idempotency. Progress events are append-only. Saving the same state twice creates duplicate records. If your engine might restart mid-save, design your restore logic to handle duplicates (e.g., use the latest meta record, or deduplicate by content).
Here's the complete flow of a minimal engine:
flowchart LR
A[Read env vars] --> B[Fetch job details]
B --> C[Clone repo]
C --> D{History exists?}
D -- Yes --> E[Restore session]
D -- No --> F[Run agentic loop]
E --> F
F --> G{Action ≠ task?}
G -- Yes --> H[Generate PR summary]
G -- No --> I[Finalize changes]
H --> I
I --> J[Save session history]
import { PlatformClient, cloneRepo, finalizeChanges } from "@github/copilot-engine-sdk";
import { CopilotClient } from "@github/copilot-sdk";
async function main() {
// 1. Read bootstrap inputs from environment
const jobId = process.env.GITHUB_JOB_ID!;
const apiToken = process.env.GITHUB_PLATFORM_API_TOKEN!;
const apiUrl = process.env.GITHUB_PLATFORM_API_URL!;
const nonce = process.env.GITHUB_JOB_NONCE;
const gitToken = process.env.GITHUB_GIT_TOKEN!;
const inferenceToken = process.env.GITHUB_INFERENCE_TOKEN!;
const platform = new PlatformClient({
apiUrl,
jobId,
token: apiToken,
nonce,
});
// 2. Fetch job details (problem statement, repo metadata, action type)
const job = await platform.fetchJobDetails();
// 3. Clone the repository
const repoLocation = cloneRepo({
serverUrl: job.server_url,
repository: job.repository,
gitToken,
branchName: job.branch_name,
commitLogin: job.commit_login,
commitEmail: job.commit_email,
});
// 4. Initialize platform client for streaming progress
const platform = new PlatformClient({ apiUrl, jobId, token: apiToken, nonce });
// 5. Build system message based on action type
const systemMessage = buildSystemMessage(job.action, job);
// 6. Run your agentic loop with your inference client
const client = new CopilotClient({
cwd: repoLocation,
githubToken: inferenceToken,
});
const mcpServerPath = require.resolve("@github/copilot-engine-sdk/mcp-server");
const session = await client.createSession({
model: "claude-sonnet-4.5",
systemMessage: { content: systemMessage },
mcpServers: {
"engine-tools": {
command: "node",
args: [mcpServerPath, repoLocation],
tools: ["report_progress"],
},
},
});
session.on((event) => {
// Map session events → platform progress events
platform.sendAssistantMessage({ ... });
platform.sendToolMessage({ ... });
});
await session.sendAndWait({ prompt: job.problem_statement.content });
// 7. Finalize — commit and push any remaining changes
finalizeChanges(repoLocation, "Finalize changes");
}Engines are consumed via GitHub Releases rather than by cloning and building the source repository. Each release should contain a self-contained tarball with everything the platform needs to run your engine: the engine.yaml definition and your compiled build output.
Tag your commit and push the tag — a GitHub Actions workflow builds the project and publishes the release automatically:
git tag v1.0.0
git push origin v1.0.0Add a workflow that triggers on version tags, builds your engine, and attaches the artifact to a GitHub Release.
Note: The example below is for a Node.js/TypeScript engine. If your engine uses a different runtime (Python, Go, Rust, etc.), substitute the appropriate setup, dependency installation, and build steps, and adjust the artifact paths to match your compiled output.
# .github/workflows/release.yml
name: Release
on:
push:
tags:
- 'v*'
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm run build
- name: Create release tarball
run: tar -czf engine.tar.gz engine.yaml dist/
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
files: engine.tar.gz
generate_release_notes: trueYour release artifact must include engine.yaml and everything it references. The entrypoint in engine.yaml is resolved as a relative path, so any files or directories it points to need to be in the tarball alongside it. For example, if your entrypoint is node dist/index.js, then dist/index.js (and any of its runtime dependencies) must be present.
The release should be self-contained — the platform should be able to extract it and run the entrypoint without cloning the repo, installing dependencies, or building from source. This means your build output must include all runtime dependencies. For Node.js engines, either use a bundler (e.g., esbuild, webpack, or ncc) to produce a single self-contained file, or include the node_modules directory in the tarball. Other runtimes should follow their equivalent strategy — for example, Go and Rust engines can compile to a static binary, while Python engines might vendor dependencies or include a virtual environment.
Your engine uses three bootstrap tokens:
| Token | Purpose | Example Prefix |
|---|---|---|
GITHUB_PLATFORM_API_TOKEN |
Platform API authentication (GET /jobs/{id}, POST /jobs/{id}/progress). |
ghp_* |
GITHUB_GIT_TOKEN |
Git operations only (clone, push). |
ghs_* / ghp_* |
GITHUB_INFERENCE_TOKEN |
Inference authentication for your model client / SDK. | ghp_* |
For inference, authenticate your model client/SDK with GITHUB_INFERENCE_TOKEN. In the TypeScript example, @github/copilot-sdk uses this token via githubToken.
Engines send clean, semantic events (assistant messages, tool results, errors) under the sessions-v2 namespace. The platform translates these into the wire format the UI renders. The PlatformClient in the SDK handles building and sending these events automatically. If you're building without the SDK, use the sessions-v2 namespace and the event schemas documented above.
When you send a progress event, it flows through an asynchronous pipeline before reaching the UI. This means there may be a brief delay between sending an event and seeing it rendered. Events are delivered reliably and in order, but not instantaneously.
Your engine runs in a secure runner environment, which means:
- Common language runtimes (Node.js, Python, Go, etc.) are usually available under the default runner image. If your runtime is not available, your entrypoint command should install it.
- Git is available on PATH
- Network access to the platform API and GitHub (for git operations)
- Standard runner filesystem behavior applies