Skip to content

Provider authoring

This page is the implementation reference for extending AML. Read Provider engineering first when you are deciding which boundary to implement; stay here when you need the exact helper, request, lease, runtime, or conformance contract.

GoalStart withSupporting public APIs
Implement one complete Agent call directlydefineAgentProvider() and AgentProviderAgentRequest, AgentExecutionContext, AgentResponse
Wrap an ACP-compatible coding agentdefineAcpAgentProvider() and AcpAgentProfileAcpAgentLaunch, AcpAgentLaunchContext, AcpAgentLaunchFile
Own a retained Agent sessionAbstractAgentProviderAgentProviderSession, AgentProviderTurn
Implement Sandbox acquisition directlydefineSandboxProvider() and SandboxProviderSandboxAcquireRequest, SandboxLease, SandboxRuntime
Implement a staged Sandbox lifecycleAbstractSandboxProviderProvisionedSandbox, SandboxRuntime
Implement Workspace materialization directlydefineWorkspaceProvider() and WorkspaceProviderWorkspaceAcquireRequest, WorkspaceLease, WorkspaceSaveRequest
Build a revision-backed WorkspacecreatePersistentWorkspaceProvider()WorkspaceStorageAdapter, WorkspacePersistenceOptions
Exercise a portable contract@aml-jsx/sdk/testingagentProviderConformance(), sandboxProviderConformance(), workspaceProviderConformance()

All production contracts above are exported from @aml-jsx/sdk. Conformance helpers and deterministic fixtures are deliberately isolated under @aml-jsx/sdk/testing.

The three structural helpers have the same narrow job:

defineAgentProvider(implementation)
defineSandboxProvider(implementation)
defineWorkspaceProvider(implementation)

Each helper validates the required provider shape and normalized name, then shallow-freezes the provider object. The Agent and Sandbox helpers preserve the concrete provider type; the Workspace helper preserves its handle type through the public WorkspaceProvider shape. Definition does not contact a vendor, install an executable, build an image, acquire a lease, or prove that the implementation honors its lifecycle.

AML intentionally has no generic defineProvider(). Agent, Sandbox, and Workspace providers receive different requests and own different resources. A single lowest-common-denominator interface would erase the constraints that make the boundaries useful.

function defineAgentProvider<const Provider extends AgentProvider>(implementation: Provider): Readonly<Provider>

The minimal provider has a normalized name, a run() method, and an optional Sandbox compatibility handshake:

interface AgentProvider {
readonly name: string
run(request: AgentRequest, context: AgentExecutionContext): Promise<AgentResponse>
supportsSandbox?(sandbox: SandboxSession): boolean
}
import { defineAgentProvider, type AgentProvider } from "@aml-jsx/sdk"
const internalReviewer: AgentProvider = {
name: "internal-reviewer",
async run(request, context) {
context.signal.throwIfAborted()
const result = await reviewService(
{
model: request.model,
prompt: request.prompt,
system: request.system,
},
{ signal: context.signal }
)
context.signal.throwIfAborted()
return { text: result.text, structured: result.structured }
},
}
export const agentProvider = defineAgentProvider(internalReviewer)

AgentRequest contains the assembled initial prompt, ordered followUps, system text, model override, portable permissions, Tools, MCP servers, staged Skills, trace identity, and optional structured-output request. Each AgentSkill exposes the concrete .agents home, package directory, and SKILL.md path; native-discovery providers consume those owned locations instead of deriving a root from path depth. One run() call represents one authored <Agent /> session. If a later turn fails, the provider fails the complete call rather than substituting an earlier response.

AgentExecutionContext supplies the evaluation signal, subscriber-only events, trace identity, and optional effective SandboxSession. Providers cannot publish AML trace events or invoke the application-owned output validator through this public context. AML still wraps retained provider sessions in portable agent.session, agent.turn, and agent.cleanup spans. A provider must reject a pre-aborted signal with its reason, observe cancellation during external work, and release invocation-owned clients, processes, streams, bridges, and temporary files before run() settles.

AgentResponse.text remains the complete concatenated text for compatibility. Built-in ACP providers also return a messages string array when every assistant text chunk carries a messageId. Chunks with the same ID are grouped in first-seen order, and messages.at(-1) is the final assistant message for the completed prompt turn. If the provider emits any text chunk without a message ID, AML omits messages rather than guessing boundaries. AML also omits messages when a provider profile transforms the assembled text because it cannot safely map that transformation back onto the original message boundaries. Provider-level consumers continue to use text in either case. Ordinary <Agent> evaluation still renders text rather than exposing the provider response object.

AML’s shared session engine currently uses the stable ACP v1 API and its optional chunk messageId. A future move to the experimental v2 session path must also consume full agent_message replacement updates; those updates are not part of the stable v1 update union.

Direct providers return one structured value in AgentResponse; AML validates it at the final application boundary. Immediate candidate validation, first-valid-result selection, and submission tracing belong to AML’s internal shared ACP adapter rather than the public provider contract.

If supportsSandbox() returns true, run() must actually launch model-controlled work through context.sandbox.lease.runtime and honor the effective root and access. The handshake cannot widen a nested Sandbox policy.

Use defineAcpAgentProvider() when the vendor already exposes an Agent Client Protocol executable. AML’s shared ACP adapter owns process launch, one-session turn order, JavaScript Tool and structured-output bridging, immediate candidate validation, first-valid-result selection, the one-prompt missing-output repair, submission tracing, MCP descriptors, temporary state, local versus Sandbox execution, abort, cleanup, and portable ACP-boundary telemetry. Profiles may customize the Tool instruction, but they must not fork that lifecycle or recovery policy.

import { defineAcpAgentProvider, type AcpAgentProfile } from "@aml-jsx/sdk"
const profile: AcpAgentProfile<"company-agent"> = {
name: "company-agent",
workingDirectory: undefined,
createLaunch({ cwd, request, stateDirectory }) {
return {
command: "company-agent-acp",
args: ["--cwd", cwd],
env: {
COMPANY_AGENT_STATE: stateDirectory,
...(request.model === undefined ? {} : { COMPANY_AGENT_MODEL: request.model }),
},
permissionPolicy: "allow_always",
}
},
}
export const companyAgent = defineAcpAgentProvider(profile)

An ACP profile owns vendor configuration and translation: command, literal arguments, environment, model/configuration mapping, permission policy, optional launch files, named-MCP support, and response transformation. Launch files must use normalized relative paths below AML’s invocation-private state directory.

The shared adapter emits one acp.session.update per ACP notification. Metadata retains only the ACP session id and update discriminant; a content-capturing sink receives the unchanged serialized ACP update. Profiles should not duplicate that stream by translating every ACP Tool, message, thought, plan, or usage variant into another AML schema. Internal model-call telemetry remains optional provider telemetry and must not be presented as an AML Agent turn. See Observability.

AbstractAgentProvider<Name> is the advanced template for an adapter that opens one AgentProviderSession per AML Agent. The base creates ordered turns, propagates cancellation, selects the final response, requests provider abort, and closes the session after success or failure. Subclasses implement openSession() and explicitly opt into compatible Sandboxes.

Use the direct run() boundary unless retained turn state and cleanup are real provider concepts. Do not reuse a session across unrelated AML evaluations.

function defineSandboxProvider<const Provider extends SandboxProvider>(implementation: Provider): Readonly<Provider>
interface SandboxProvider<Handle = unknown> {
readonly name: string
acquire(request: SandboxAcquireRequest): Promise<SandboxLease<Handle>>
}

SandboxAcquireRequest contains the effective access, logical root and cwd, evaluation id, cancellation signal, and optional active Workspace materialization. The returned lease contains an opaque provider handle, stable lease id, portable runtime, and release().

import { defineSandboxProvider, type SandboxProvider } from "@aml-jsx/sdk"
const internalSandbox: SandboxProvider<{ environmentId: string }> = {
name: "internal-sandbox",
async acquire(request) {
request.signal.throwIfAborted()
const environment = await provisionEnvironment(request, request.signal)
let releasePromise: Promise<void> | undefined
const runtime = {
access: request.access,
cwd: request.cwd,
root: request.root,
createFileStaging: options => environment.createFileStaging(options),
exec: (command, args, options) => environment.exec(command, args, options),
readFile: (path, options) => environment.readFile(path, options),
spawn: (command, args, options) => environment.spawn(command, args, options),
stat: (path, options) => environment.stat(path, options),
writeFile: (path, content, options) => environment.writeFile(path, content, options),
}
return {
handle: { environmentId: environment.id },
id: environment.id,
runtime,
release: () => (releasePromise ??= environment.destroy()),
}
},
}
export const sandboxProvider = defineSandboxProvider(internalSandbox)

The runtime must preserve the acquired access, root, and cwd. exec() receives one executable plus literal arguments and returns bounded stdout, stderr, and exit code. spawn() exposes standard Web streams, repeat-safe kill(), and repeat-safe wait(). readFile(), stat(), and writeFile() operate on complete files beneath the logical Sandbox root; stat() returns the kind and byte size, plus modification time in Unix milliseconds when available. AML does not retain Include results from providers that omit modification time. Writes must replace rather than append and reject read-only access. createFileStaging() returns a unique writable Agent-visible root with confined relative writes and repeat-safe release, even when the live Workspace filesystem is read-only. Provider shell quoting is an implementation detail; do not silently merge arbitrary arguments into one shell string.

The implementation owns path translation, traversal and symlink confinement, complete-file metadata and bytes, timeouts, signal propagation, combined output limits, process-tree cleanup, access enforcement, staging cleanup, and release after partial failure. If the backend cannot enforce read-only execution, reject command/process work under access="read-only" rather than recording a flag and continuing. Read-only live files may still be read, and invocation-private Agent staging must remain writable because it is outside durable Workspace state.

AbstractSandboxProvider<Name, Handle, Resource> owns the common acquisition barrier:

  1. provision() creates an acknowledged provider resource.
  2. createRuntime() translates it into AML’s portable process and filesystem runtime.
  3. initialize() performs optional hydration or trusted setup.
  4. any failure after provisioning calls cleanupProvisioned();
  5. the exposed lease memoizes releaseResource() so release is idempotent.

Use ProvisionedSandbox<Handle, Resource> to keep the descendant-facing opaque handle separate from the internal resource needed for reconciliation and destruction.

defineSandboxProvider() validates shape and freezes the adapter. It does not prove the environment is isolated. Provider documentation must state the actual host/container/platform, image, user, network, credentials, resource controls, filesystem behavior, and cleanup boundary.

function defineWorkspaceProvider<Handle>(provider: WorkspaceProvider<Handle>): Readonly<WorkspaceProvider<Handle>>
interface WorkspaceProvider<Handle = unknown> {
readonly name: string
acquire(request: WorkspaceAcquireRequest): Promise<WorkspaceLease<Handle>>
}
import { defineWorkspaceProvider, type WorkspaceProvider } from "@aml-jsx/sdk"
const internalWorkspace: WorkspaceProvider<{ directory: string }> = {
name: "internal-workspaces",
async acquire(request) {
// This helper throws WorkspaceConflictError only for a healthy competing writer.
const lock = request.lock === false ? undefined : await acquireWriterLock(request.id, request.signal)
let directory: string
try {
directory = await materialize(request.id, request.load, request.signal)
} catch (error) {
await lock?.release().catch(() => undefined)
throw error
}
let released = false
return {
directory,
handle: { directory },
id: request.id,
save: async saveRequest =>
publishRevision(request.id, directory, saveRequest, saveRequest?.signal ?? request.signal),
async release() {
if (released) return
released = true
try {
await removeMaterialization(directory)
} finally {
await lock?.release()
}
},
}
},
}
export const workspaceProvider = defineWorkspaceProvider(internalWorkspace)

The storage functions above are application placeholders, not AML APIs. A real provider acquires healthy writer authority before expensive materialization, removes partial state when materialization fails, publishes only complete validated saves, and relinquishes locks and temporary files even if saving fails.

Use WorkspaceConflictError only for a healthy competing writer of the same durable id. Stale conditional publication, invalid archives, missing objects, access denial, and cleanup failure remain ordinary provider errors with their original causes.

WorkspaceSaveRequest carries outcome, retention, include/exclude, gitignore, and cancellation policy. Retention includes the newly published revision. Document whether the provider directly exposes a durable directory, stages a temporary copy, publishes folder/archive revisions, or requires a provider-native volume.

If the backend is object storage, implement WorkspaceStorageAdapter and pass it to createPersistentWorkspaceProvider(). AML’s persistence layer owns revision indexes, conditional publication, locks, retention, and materialization over that narrower storage contract. This is the extension seam used by revision-oriented providers; it avoids reimplementing the persistence protocol in every S3-compatible adapter.

import {
agentProviderConformance,
sandboxProviderConformance,
workspaceProviderConformance,
} from "@aml-jsx/sdk/testing"
await agentProviderConformance(agentProvider)
await sandboxProviderConformance(sandboxProvider)
await workspaceProviderConformance(workspaceProvider)

The helpers are ordinary async functions and do not require a particular test runner. They check the portable boundary, including pre-cancelled Agent execution, Sandbox lease and runtime shape plus release, and Workspace conflict/save/release/restoration behavior. Sandbox command and filesystem behavior remains the responsibility of provider-specific tests.

Import extension contracts only from @aml-jsx/sdk and testing helpers only from @aml-jsx/sdk/testing. Paths below sdk/src/** are implementation details even when these docs link to them as readable source.

Public advanced helpers such as AbstractAgentProvider, defineAcpAgentProvider, AbstractSandboxProvider, and createPersistentWorkspaceProvider are supported extension seams. Internal validators, evaluator state, ACP transport classes, provider package construction seams, and direct source paths are not public imports.

Every custom provider should document:

  • maturity and exact versions exercised;
  • required executable, image, snapshot, SDK, credentials, and external service;
  • supported Agent/Sandbox/Workspace combinations;
  • path, cwd, access mode, and what “read-only” actually enforces;
  • session, process, materialization, revision, and retention semantics;
  • cancellation, timeout, output bounds, cleanup, and possible remote leftovers;
  • conflict identity versus persistence/provider failures;
  • content or credential fields that can reach provider-native logs and AML traces.

Common contract failures include starting work on a pre-aborted signal, releasing only on success, claiming an unenforced access mode, leaking lifecycle authority through a handle, publishing a partial Workspace snapshot, and describing an image as Agent-ready without verifying its ACP executable.

The normative architecture remains in SPEC.md, while PROVIDERS.md tracks built-in implementation status.