Skip to main content

Authoring Plugins

This guide is for implementing Assembly Line plugin contributions — providers (channels, sandboxes, blob stores, deploy targets, state stores), connection plugins, sandbox-CLI tools, and bundled skills — and packaging them so agents can use them with zero Assembly Line core or host edits. Plugins defines the user-facing plugin model and catalog; Adapters is the companion reference for consuming the adapters that ship in this repo.

A plugin package exports up to two well-known symbols:

  • openeveProvider — provider registrations constructed by the runtime for an adapter role (state, blob, sandbox, deploy, channel, connection).
  • openevePlugin — connection-plugin metadata consumed by the CLI and compiler, paired with a define<X>Connection helper agents call in connections/<kind>.ts.

Contents:

The Provider Contract (openeveProvider)

Provider registration is open: any npm package can supply a state, blob, sandbox, deploy, channel, or connection provider. A plugin with provider contributions exports one well-known symbol:

import type { ProviderModule } from "@assemblyline-agents/core";

export const openeveProvider: ProviderModule = {
providers: [
{
metadata: {
kind: "neon-state", // the adapter kind used in gateway.ts
role: "state", // deploy | runtime | state | blob | sandbox | scheduler | channel | connection
packageName: "@acme/openeve-neon",
stability: "preview", // supported | preview | planned
requiredEnv: ["NEON_DATABASE_URL"],
optionalEnv: ["NEON_POOL_SIZE"],
capabilities: ["durable-state"]
},
create(ctx) {
// ctx: { role, kind, options, env, fetch, manifest?, artifactRoot?, devMode }
return new NeonStateAdapter(ctx.env.NEON_DATABASE_URL!, ctx.options);
}
}
]
};

The plugin package owns its provider metadata; the built-in registry in @assemblyline-agents/core is only an offline mirror for the adapters that ship in this repo (a test asserts they never drift). create(ctx) receives everything construction needs — adapter options merged with host-supplied role extras, environment, fetch, the compiled manifest, and the artifact root — and returns the instance for the role: a StateAdapter/StateStores facet, BlobAdapter, SandboxAdapter, DeployPublisher, channel/connection implementation, or another matching adapter contract. (Telemetry sinks are not adapters — they are wired in instrumentation.ts; see Customizing Agents → Observability.)

Agents opt in through the third adapter() argument:

export default defineGateway({
runtime: adapter("node"),
state: adapter("neon-state", { pool: 4 }, { package: "@acme/openeve-neon" })
});

Resolution order at runtime: built-in kinds keep their local defaults, then packageName on the adapter definition wins, then the built-in metadata registry's packageName. The Node host imports the package (also resolving from the deployed artifact's node_modules), reads openeveProvider, and calls the registration matching the role and kind. Failures are specific and early: no known package for the kind, a package without the openeveProvider export, or a package without a matching role/kind registration each produce a distinct boot error naming the package. assembly-line deploy uses the same mechanism for deploy targets beyond local/railway/docker/fly/vps.

Preflight picks up third-party requirements at build time: when the compiler sees a packageName it cannot find in the built-in registry, it imports the package from the agent root, stamps the resolved requiredEnv, setup, capabilities, and stability into manifest.providerMetadata, and emits the matching env requirements into manifest.preflight — so assembly-line deploy --dry-run reports missing provider env exactly like it does for built-ins. If the package cannot be resolved, the compiler keeps the generic unknown-kind requirement and adds a provider-package-unresolved validation warning instead of failing the build; the Node host re-validates at boot.

Connection providers whose executable must run on a particular host declare hostRequirements: { deployTargets?, platforms?, message } in their metadata. Assembly Line carries those constraints into the manifest, deployment plan, and runtime platform check. Use this for genuine execution requirements, not provider preferences — the built-in Peekaboo plugin (local + darwin) is the reference.

Connection Plugins (openevePlugin)

A connection plugin gives agents a reviewed external capability — MCP server, OpenAPI service, or provider CLI — behind the standard connection access, approval, discovery, and subject-scoping model. The package exports two things: a define<X>Connection helper agents call in connections/<kind>.ts, and the openevePlugin module built with definePlugin:

import type { PluginModule } from "@assemblyline-agents/core";

export interface PluginModule {
connections?: ConnectionPluginMetadata[];
}

The official Notion plugin is the minimal complete reference — five lines, because its metadata lives in the built-in catalog:

// packages/notion/src/index.ts
import { connectionPluginMetadata, defineMcpPluginConnection, definePlugin, type McpPluginConnectionOptions } from "@assemblyline-agents/core";
const PLUGIN = connectionPluginMetadata("notion")!;
export type NotionConnectionOptions = McpPluginConnectionOptions;
export function defineNotionConnection(options: NotionConnectionOptions) { return defineMcpPluginConnection(PLUGIN, options); }
export const openevePlugin = definePlugin({ connections: [PLUGIN] });

A community plugin supplies its own metadata object instead of calling connectionPluginMetadata:

// src/index.ts
import {
defineMcpPluginConnection,
definePlugin,
type ConnectionPluginMetadata,
type McpPluginConnectionOptions
} from "@assemblyline-agents/core";

const PLUGIN: ConnectionPluginMetadata = {
kind: "acme",
role: "connection",
packageName: "@yourscope/openeve-acme",
helper: "defineAcmeConnection",
protocol: "mcp",
transport: "http",
provider: "acme",
description: "Acme issues and projects through Acme MCP.",
defaultUrl: "https://mcp.acme.dev/mcp",
urlEnv: "ACME_MCP_URL",
tokenEnv: "ACME_MCP_TOKEN",
tokenRequired: true,
requiredEnv: ["ACME_MCP_TOKEN"],
optionalEnv: ["ACME_MCP_URL"],
readToolPatterns: ["regex:^(get|list|search)_"],
writeToolPatterns: ["regex:^(create|update|delete)_"],
skill: "acme",
setup: [{
kind: "connection",
name: "acme-developer-configuration",
required: true,
message: "Create an Acme API token and set ACME_MCP_TOKEN."
}]
};

export type AcmeConnectionOptions = McpPluginConnectionOptions;
export function defineAcmeConnection(options: AcmeConnectionOptions) {
return defineMcpPluginConnection(PLUGIN, options);
}
export const openevePlugin = definePlugin({ connections: [PLUGIN] });

ConnectionPluginMetadata

ConnectionPluginMetadata extends the generic AdapterProviderMetadata (kind, role, stability, packageName, requiredEnv, optionalEnv, capabilities, setup) and is shared by the CLI, compiler, and your helper:

FieldTypeRequiredEffect
kindstringyesThe connection kind: the assembly-line add argument and the connections/<kind>.ts filename.
role"connection"yesAlways "connection" for plugin metadata.
helperstringyesExported helper name; assembly-line add writes import { <helper> } from "<packageName>" into the scaffolded connection file, and factory error messages name it.
protocol"mcp" | "openapi" | "cli"yesSelects the factory family; each factory rejects mismatched metadata.
transport"http" | "stdio" | "relay" | "sandbox"noDefaults to Streamable HTTP ("http") when omitted. "sandbox" pairs only with protocol: "cli".
providerstringyesProvider identity stamped into metadata.provider on every generated definition.
descriptionstringyesDefault connection description when the agent author passes none.
commandstringfor cliThe sandbox CLI executable name; defineSandboxCliPluginConnection fails without it (unless the author overrides).
defaultUrlstringnoEndpoint fallback when neither options.url nor urlEnv supplies one.
urlEnvstringnoEnv var consulted for the endpoint URL (before defaultUrl). Required env when there is no defaultUrl.
defaultSpec / specEnvstringOpenAPI onlyOpenAPI specification source fallback and env override.
defaultBaseUrl / baseUrlEnvstringOpenAPI onlyAPI base URL fallback and env override.
tokenEnvstringnoEnv var holding the credential. Default auth sends it as a Bearer token.
tokenRequiredbooleannofalse makes tokenEnv optional and skips default auth when the var is unset.
tokenHeaderstringnoSend the tokenEnv value verbatim in this header instead of Bearer authorization (for example x-browser-use-api-key).
credentialEnvstringrelay onlyEnv var containing one opaque, device-scoped relay binding; defineRelayMcpPluginConnection fails without it.
scopesstring[]noOAuth scopes advertised for authorization flows.
subject"user" | ...noDefault connection subject (per-user vs environment credential scoping) when the author passes none.
tools{ allow: [...] } | { block: [...] }noDefault tool allow/block filter cloned into generated definitions; entries use the exact, * glob, or regex: syntax described below, and the author's tools option overrides it.
hostRequirements{ deployTargets?, platforms?, message }noRuntime-host restrictions for process-backed or platform-specific plugins; enforced at validation, deploy planning, and boot.
readToolPatternsstring[]yesTool-name patterns classified as read authority.
writeToolPatternsstring[]yesTool-name patterns classified as write authority. An empty array makes the plugin read-only.
skillstringnoSingle bundled skill directory name copied by assembly-line add.
skillsstring[]noMultiple bundled skill directory names (use one of skill/skills).
requiredEnv / optionalEnvstring[]yes/noEnv vars surfaced by assembly-line add and stamped into preflight. Keep them consistent with the URL/token fields above.
setup{ kind, name, required, message }[]noHuman setup steps assembly-line add prints and preflight reports.
stability"supported" | "preview" | "planned"noSupport level surfaced in metadata and manifests.
packageNamestringyes for communityThe npm package assembly-line add installs and the scaffold imports from.

Tool Classification Patterns

readToolPatterns and writeToolPatterns classify every discovered tool name:

  • A bare string matches the exact tool name.
  • A string containing * is a glob (arcads_get_*).
  • A regex: prefix compiles the remainder as a case-insensitive regular expression, e.g. regex:^(get|list|search)_.

Write matches take precedence over read matches, and unclassified tools are not discoverable at all — the classification is the plugin's security surface, so review it against the provider's real tool list rather than trusting naming conventions.

Access Policy

Agent authors pass one small, explicit policy to your helper:

interface ConnectionPluginAccessSelection {
read: true;
write: false | {
approval: "always" | "once" | "never" | ConnectionApprovalDefinition;
};
}

Every factory runs validatePluginAccess before building the definition: access must be present, and enabling write on a plugin whose writeToolPatterns is empty throws <helper>: <provider> is a read-only plugin and cannot enable writes. The factory then expands the selection into a full ConnectionAccessDefinition using your metadata's read/write patterns, so the agent folder shows the policy while the plugin owns the classification.

The define*PluginConnection Factories

Choose the factory matching your metadata; each one rejects mismatched protocol/transport with an error naming the correct factory:

MetadataFactoryProduces
protocol: "mcp", transport: "http" (default)defineMcpPluginConnection(PLUGIN, options)Streamable HTTP MCP connection
protocol: "mcp", transport: "stdio"defineStdioMcpPluginConnection(PLUGIN, options)Static stdio MCP process (options must pass command)
protocol: "mcp", transport: "relay"defineRelayMcpPluginConnection(PLUGIN, options)E2EE device-relay MCP connection (metadata must declare credentialEnv)
protocol: "openapi"defineOpenAPIPluginConnection(PLUGIN, options)Direct OpenAPI connection from defaultSpec/specEnv
protocol: "cli", transport: "sandbox"defineSandboxCliPluginConnection(PLUGIN, options)Reviewed CLI invocations in the run sandbox (options must pass tools)

Shared behavior the factories give you for free:

  • URL resolution: options.url, else the urlEnv environment variable, else defaultUrl; a missing URL throws an error naming urlEnv. OpenAPI resolves spec and baseUrl the same way from their fields.
  • Auth: unless the author passes an auth definition (or auth: false), the factory builds a Bearer-token auth from tokenEnv — skipped when tokenRequired: false — or, when tokenHeader is set, sends the env value verbatim in that header instead.
  • Defaults: description, subject, and the tools filter fall back to metadata; required defaults to true; metadata.provider and metadata.plugin are stamped so tracing and audits attribute tool calls to the plugin.

Sandbox CLI Tools

A sandbox-CLI plugin (protocol: "cli", transport: "sandbox") exposes a provider's official CLI as a small set of reviewed tools that execute inside the active run sandbox — the process sees /files and /workspace, and never runs on the gateway host. @assemblyline-agents/higgsfield is the reference implementation.

defineSandboxCliPluginConnection(PLUGIN, options) requires options.tools: SandboxCliToolDefinition[]:

interface SandboxCliToolDefinition {
name: string; // qualified as <kind>__<name>, e.g. higgsfield__read
description: string;
inputSchema: JsonSchema; // the model-facing input contract
outputSchema?: JsonSchema;
build(input: unknown): MaybePromise<SandboxCliInvocation>;
}

interface SandboxCliInvocation {
args: string[]; // CLI arguments, excluding the executable
cwd?: string; // e.g. "/workspace"
timeoutMs?: number;
hydratePaths?: string[]; // logical paths to materialize before launch
}

Contract and safety rules:

  • The command is trusted configuration. The executable comes from PLUGIN.command (or an author override in the connection file), never from the model. build(input) receives the model's JSON input and returns the argument vector; the runtime passes every element of args as an individually quoted argument — model-authored shell source is never accepted.
  • Validate inside build. Treat it as your allowlist: check the subcommand against a reviewed read or write set, reject credential-printing and auth subcommands, and bound argument count and length. Throw a specific error for anything outside the reviewed surface (see the Higgsfield package's validateReadCommand/validateWriteCommand).
  • hydratePaths lists logical paths (/files/..., /workspace/...) that must exist in the sandbox filesystem before the process starts — return every attachment path referenced by the arguments so the CLI can read uploaded media.
  • timeoutMs bounds each invocation; pick separate read and write defaults when writes are long-running jobs.
  • maxOutputChars (a connection-level option) caps captured stdout/stderr before it reaches the model.
  • Access classification applies to the tool names: a common shape is one read tool and one write tool with readToolPatterns: ["read"] and writeToolPatterns: ["write"], so the write tool stays hidden until the connection enables writes with an approval policy.
  • Credentials that live in the CLI's own login state (like higgsfield auth login) belong to a persistent, user-scoped sandbox — document the interactive login in setup and never bake them into a shared image.

How assembly-line add Reads Your Package

assembly-line add @yourscope/openeve-acme <agentRoot> installs the package, then imports it and inspects the two well-known exports (on the module or its default export):

  1. openevePlugin.connections — each entry becomes an installable connection contribution (with packageName defaulted to the installed package).
  2. openeveProvider.providers — each registration's metadata becomes an installable provider contribution.

Selection rules:

  • --role <role> picks the matching contribution, or fails with Package <name> has no <role> provider. Available: <role>:<kind>, ....
  • Without --role, a single-role package is unambiguous. A multi-role package fails with Package <name> provides multiple roles: <roles>. Pass --role <role>.
  • A package exporting neither symbol fails with Plugin package <name> does not export assemblyLinePlugin/openevePlugin or assemblyLineProvider/openeveProvider, so its contributions cannot be determined.
  • With --no-install and the package absent, the import fails with Failed to load plugin package <name>: ... Install it first (or rerun without --no-install).

After selection, the CLI wires the agent folder exactly as it does for official plugins — connection file, channel file, or gateway.ts slot — and prints your metadata's requiredEnv, optionalEnv, and setup messages. See Plugins: Install A Plugin for the per-role scaffold behavior.

Bundled Skills

A connection plugin should ship at least one skill teaching the agent how to operate the integration:

  • Put each skill at skills/<name>/SKILL.md in the package root (plus any reference files), and include "skills" in the package.json files array so npm publishes it.
  • List the directory names in metadata as skill: "<name>" or skills: ["<a>", "<b>"] — only listed skills are copied.
  • assembly-line add copies each listed directory into the agent's skills/<name>/. An existing directory is never overwritten, and a listed skill without a SKILL.md is reported as an error line.

Skills must never contain secrets, tokens, or account-specific values — credentials stay in host environment variables and authorization flows.

Channel Modules

A channel package exports a ChannelModule (defined in @assemblyline-agents/runtime's channel.ts). Every member is optional — implement only what your provider needs:

MemberPurpose
normalizeHttp(request, ctx)Verify and normalize an inbound HTTP request into a turn. Returns { kind: "run" }, { kind: "accepted" } (ACK before model work), { kind: "response" } (reply without a run, e.g. a 401), or { kind: "ignored" }.
startIngress(ctx, emit)Long-lived provider listener (e.g. Discord Gateway). emit.accepted({ turn, idempotencyKey, ... }) feeds the same durable run path as HTTP webhooks and reports capacity rejections with retryAfterMs.
startTurn(turn, ctx)Turn lifecycle hook — typing indicators and similar. Accepted HTTP ingress starts it after capacity/idempotency admission, concurrently with runtime initialization and durable run creation; other runs start it during setup. The runtime stops it before final delivery.
augmentContext(turn, ctx)Add recentHistory/channelContext after ACK and before context bundle construction.
send(delivery, ctx)Deliver the final response through the provider API.
ingressAuth{ requiredSecretEnv?: string[][] } — production ingress-auth declaration (see below).
resolveAttachment(attachment, ctx)Turn a turn attachment into an authenticated download request (see below).

Every member receives the same ChannelContext: the compiled channel, agentScope, resolved connections, env, fetch, state, and blob.

Guidance that keeps channels durable and safe:

  • Idempotency. Providers redeliver webhooks. Use the provider's stable delivery id (Slack event_id, Spectrum webhook id + message id, Telegram update_id) as the turn's idempotencyKey so retries never start duplicate runs, and return { kind: "accepted" } before model work for providers that enforce fast ACK deadlines.
  • Verify every request. normalizeHttp owns signature/token verification. Compare secrets with constantTimeSecretEqual from @assemblyline-agents/runtime and return { kind: "response", status: 401, ... } on mismatch. Provider channel routes are public in production — verification is your only gate.
  • Declare ingress secrets. Set ingress: { requiredSecretEnv: [["MY_WEBHOOK_SECRET"], ["MY_BEARER_TOKEN"]] } on the channel config (any-of groups: boot succeeds when every var in at least one group is set) and export the same shape as ingressAuth on the module. The compiler stamps it into CompiledChannel.metadata.ingress; production boot fails until a group is satisfied, dev mode warns.
  • Resolve your own attachments. resolveAttachment returns { url, headers, filename? } with your provider's credentials and host allowlist; the runtime performs the download, applies size/type limits and timeouts, and stores the blob. Return undefined to fall back to a generic unauthenticated URL download. The trust boundary is enforced by the runtime: only the module of the channel that produced the turn is ever consulted, and only when the attachment's declared provider matches, so a hostile attachment can never route to another channel's credentials. Use attachmentTrustedForProvider(attachment, "<provider>") and attachmentRemoteUrl(attachment) from @assemblyline-agents/runtime before attaching credentials.
  • Delivery results. A send failure is retried in-process and then deferred onto the durable delivery queue when retryable. Throw for transient provider errors; the runtime treats failures as retryable unless marked otherwise. When resending from the queue, the original turn may be gone — fall back to the persisted payload.delivery target.
  • Outbound HTTP. Use the shared runtime HTTP client (fetchWithPolicy/fetchJson from @assemblyline-agents/runtime) for provider API calls: per-attempt timeouts, Retry-After handling, backoff with jitter, and size-capped bodies come for free, and the fetchImpl parameter preserves the ctx.fetch injection seam tests rely on.

The Slack, Telegram, Teams, Discord, and Photon packages are the reference implementations for all of the above.

assembly-line add has channel scaffolds only for Slack, Discord, Telegram, and Teams. For any other channel kind — including a community channel package — it prints No channel scaffold is known for "<kind>"; document that users create channels/<kind>.ts exporting your ChannelDefinition manually.

Sandbox Adapters

Implement SandboxAdapter from @assemblyline-agents/runtime. Only acquire is required; the optional members opt into warm reconnects and the dirty-session retention that sandbox sync depends on:

interface SandboxAdapter {
provider?: string;
acquire(run: RunRecord): Promise<SandboxSession>;
create?(run, input?): Promise<SandboxSession>;
lookup?(input): Promise<SandboxLookupResult | undefined>; // { state: "live" | "warm" }
connect?(input): Promise<SandboxSession | undefined>;
wake?(input): Promise<SandboxSession | undefined>;
pauseOrRetain?(session, input?: { dirty?: boolean; reason?: string }): Promise<void>;
disposeClean?(session): Promise<void>;
}

Rules the built-in adapters follow and yours should too:

  • Hosted adapters must establish a physical /workspace, make it the default shell cwd, and ensure shell operations and provider file APIs address the same files. Validate the invariant after create, connect, and wake with sandboxFilesystemContractProbeCommand() and assertSandboxFilesystemContractProbe().
  • Stamp sandboxFilesystemContractMetadata() into provider labels/tags and runtime manifests, filter provider lookup by that metadata, require hasCurrentSandboxFilesystemContract() before reconnect, and include sandboxFilesystemContractKey(sessionKey) in provider resource names. This prevents old namespaces from being silently reused after a contract change.
  • Paths use the canonical Assembly Line namespace. Use the shared helpers from @assemblyline-agents/corenormalizeSandboxPath, normalizeSandboxRoot, resolveSandboxPath (rejects .. traversal and retired /runtime), canonicalizeSandboxListingPath, assertCanonicalSandboxWorkingDirectory, and shellQuote — instead of reimplementing path handling.
  • Listings return virtual absolute paths and include contents so runtime sync can persist memory and artifacts without provider-specific follow-up reads.
  • listFiles(path, { excludeTopLevel }) must prune named direct child directories before reading file contents.
  • Implement deletePath when possible so the core delete tool does not have to shell out.
  • Dirty sessions are retained/paused (not destroyed) while sandbox sync is pending; clean sessions are disposed through the provider lifecycle API.
  • Never fall back to local execution silently.

LocalSandboxAdapter (@assemblyline-agents/runtime) is the explicitly dev-only logical emulation reference; @assemblyline-agents/docker is the reference for the physical namespace and a real isolation boundary.

Blob Adapters

BlobAdapter is two methods:

interface BlobAdapter {
put(key, value, contentTypeOrOptions?): Promise<BlobRecord>;
get(key): Promise<Uint8Array | undefined>;
}

put accepts a content type string or { contentType?, visibility? } and returns a BlobRecord (id, key, uri, sha256, size, ...). Blobs are private by default; only return public HTTP URLs when the write explicitly used { visibility: "public" }. @assemblyline-agents/s3 is the reference implementation.

Deploy Publishers

Deploy targets implement DeployPublisher from @assemblyline-agents/core:

interface DeployPublisher {
target: string;
preflight?(artifact: DeployArtifact, plan: DeployPlan): Promise<DeployReceipt | void>;
prepare?(artifact: DeployArtifact, plan: DeployPlan): Promise<DeployReceipt | void>;
publish(artifact: DeployArtifact, plan: DeployPlan): Promise<DeployReceipt>;
rollback?(artifact: DeployArtifact, plan: DeployPlan): Promise<DeployReceipt>;
destroy?(artifact: DeployArtifact, plan: DeployPlan, options?: { purgeData?: boolean }): Promise<DeployReceipt>;
syncSecrets?(secrets: Record<string, string>, plan: DeployPlan): Promise<DeploySecretsReceipt>;
runMigrations?(
artifact: DeployArtifact,
plan: DeployPlan,
request: { entries: string[]; command?: string }
): Promise<string>;
runRemoteCommand?(
artifact: DeployArtifact,
plan: DeployPlan,
command: string[],
options?: { interactive?: boolean; silent?: boolean; release?: "active" | "prepared" }
): Promise<DeployCommandResult>;
}
// DeployArtifact.manifest may include deploymentRequirements:
// { persistentDirectories: [{ id, path, sensitive }], remoteExecution }
// DeployPlan: { target, environment, agentRevision, agentId?, defaultEnvironment?, requirements? }

A publisher must isolate resources per plan.environment. How is the target's choice: native environments (railway), name scoping via environmentScopedName(base, plan) from @assemblyline-agents/core (docker, fly), or identity hashing (vps). The helper encodes the compatibility rule — the default environment keeps unscoped legacy names, every other environment gets a -<environment> suffix — so adopt it rather than inventing a scheme. destroy follows the same feature-detection pattern as rollback: omit it if the target cannot remove resources safely, re-derive names from the plan (no receipt is available), and keep durable data unless options.purgeData.

publish receives the built .assembly-line artifact root plus the plan, shells out to provider tooling (the built-ins take a RunDeployCommand so tests can inject a fake), and returns a receipt. The CLI adds migration status and writes the final deployment.json after publish succeeds. preflight, prepare, and runMigrations are optional: remote-host publishers can validate the target, stage an immutable image, and migrate beside a private database before activation. Register the publisher with role "deploy" in your openeveProvider and assembly-line deploy --target <kind> picks it up through the resolver — no CLI edits. @assemblyline-agents/railway, @assemblyline-agents/docker, @assemblyline-agents/fly, and @assemblyline-agents/vps are the references.

syncSecrets is optional and should be implemented only for hosted targets that have a remote secret store. It receives key/value pairs from assembly-line deploy --sync-secrets and must log or return key names only, never secret values.

runRemoteCommand is optional for backward compatibility. Implement it when a publisher advertises remote-exec; execute the exact argument vector without interpolating it unsafely into a shell. A publisher that advertises persistent-storage must provision every declared persistent directory and must treat entries marked sensitive as credential-bearing. Do not place their contents in secrets, logs, artifacts, or receipts. The built-in Codex auth flow requires both capabilities plus this method; ordinary artifacts can still use older community publishers.

Scheduler Adapters

The scheduler role selects where the schedule clock lives. The runtime contract is SchedulerAdapter from @assemblyline-agents/runtime:

interface SchedulerAdapter {
kind: string;
start(runtime: RuntimeSchedulerHost): MaybePromise<RuntimeSchedulerController | undefined>;
}
// RuntimeSchedulerHost: { startSchedulerPollingLoop(), registerManifestSchedules() }
// RuntimeSchedulerController: { stop(), tick() }

The built-ins cover the three shapes: local and postgres start the runtime's polling loop (Postgres adds multi-worker lease coordination through the state adapter); gateway registers the manifest schedules and returns no controller, leaving ticks to an external trigger calling /assembly-line/automations/tick or runtime.runDueAutomations().

An unknown scheduler kind in gateway.ts falls back to gateway-style behavior — schedules are registered, but no in-process loop starts. A custom in-process scheduler is therefore an embedder seam rather than a package-resolved provider: hosts constructing the runtime directly pass their own SchedulerAdapter as RuntimeOptions.scheduler. See Adapters: Scheduler for the consumption view.

State Adapters

Durable state is capability-faceted. Start from RunStore — the only required facet — and add facets as your backend supports them:

  • RunStore (required): runs, run events, tool calls, checkpoints, deliveries, and idempotency keys (createRun, updateRun, getRun, listRuns, appendEvent, listEvents, createToolCall, updateToolCall, listToolCalls, createCheckpoint, listCheckpoints, createDelivery, updateDelivery, listDeliveries, reserveIdempotencyKey).
  • Optional RunStore methods unlock durability features: leaseDueDeliveries, completeDeliverySend, failDeliverySend, and recoverExpiredDeliveries enable the durable delivery queue (make the lease multi-replica safe — Postgres uses for update skip locked); listRunsByStatus makes orphan sweeps efficient; touchRun gives the run heartbeat a guarded write that bumps updatedAt only while the run is still created/running — implement it with a single conditional statement (never read-modify-write) so a heartbeat can never race a status transition; requestRunControl and settleRunControl must atomically apply cooperative suspend/cancel with cancel precedence across replicas; close participates in graceful shutdown.
  • Optional facets: ConversationStore, ScheduleStateStore, FileIndexStore, UsageStore, SandboxSessionStore, MemoryStateStore, RuntimeSettingsStore. Hand them to the runtime as a StateStores object ({ runs, conversations?, schedules?, files?, usage?, sandboxSessions?, memory?, settings? }); missing facets fall back to in-memory implementations with a single state.degraded boot warning. StateAdapter remains the backward-compatible historical monolith; ResolvedStateAdapter is the full internal intersection after the runtime adds the settings fallback.

UsageStore is observational. Implement recordUsage and listUsage, with queryUsage and summarizeUsage for efficient reporting. Writes should be idempotent on the provider/request key and should allow a later provider-reconciled receipt to replace an unavailable observation. Usage-store failures must be surfaced through logs and degraded-state reporting, but must not reject model requests or suppress provider responses.

References, in order of approachability: the per-facet InMemory*Store classes and FileStateAdapter in @assemblyline-agents/runtime, then PostgresStateAdapter in @assemblyline-agents/postgres (migrations, leases, multi-replica coordination). Capability guards (isRunStore, isStateAdapter, ...) are exported for feature detection.

Agent Engines Are Not Plugin Providers

The primary model engine is not an adapter role. Pi (@assemblyline-agents/pi) is the default engine; agent.ts rejects a harness: slot at validate time. The Node host additionally owns a preview openai-codex/* provider-prefix route through RuntimeOptions.modelHarnesses. That internal host route is not a provider-registered engine role. The internal AgentHarness contract in @assemblyline-agents/core remains the seam the runtime speaks through — it keeps the runtime free of engine types, continuations opaque JSON, and durability runtime-owned — and RuntimeOptions.agentHarness exposes it to embedders and tests (the durability suite drives scripted engines through it). It is not a provider-registered extension point.

Subagents use Pi too. They are configured with an optional model, workspace, tool set, and connection set; there is no subagent provider role and no public harness adapter contract. Service-specific execution surfaces such as LiveKit should expose typed tools and connection definitions instead.

Capability Tier Or Connection Plugin?

Build a connection plugin when the integration is an external capability an agent calls as tools — an MCP server, an API, or a reviewed CLI — behind the standard access/approval model. Build a capability package (under capabilities/, like LiveKit) only when the integration is a single-vendor feature with its own execution surface — typed tool definitions, clients, and connection metadata that expose the vendor's own concepts rather than an interchangeable adapter role or a discoverable tool catalog. When in doubt, prefer a connection plugin: it gets assembly-line add scaffolding, read/write classification, approvals, and skills for free. See Adapters: Capabilities.

Publishing To npm

  • Naming. Use @yourscope/openeve-<thing> (for example @acme/openeve-neon). Official packages are @assemblyline-agents/<kind>.
  • ESM with an exports map. The CLI, compiler, and Node host all load your package with dynamic import(). Ship ESM ("type": "module") with an exports entry resolving to your built output, and export openevePlugin/openeveProvider from that entry (a default export containing them also works).
  • Depend on @assemblyline-agents/core as a peer dependency so your metadata and definition types come from the host's single core instance.
  • Ship your skills. Include "skills" (and your dist) in the package.json files array.
  • Artifact packaging is automatic. The compiled .assembly-line/package.json declares every community plugin package used by the agent as a dependency — pinned to the version installed in the agent root when present, else the agent's declared range, else * — so npm install --omit=dev in the deployed artifact pulls your package without manual edits.
  • Document alongside the package: required/optional env, provider setup steps (OAuth app registration, CLI installs), verification behavior, idempotency keys, and the exact read/write tool surface.

Testing Your Plugin

Copy the patterns from this repo's acceptance tests (Node node:test + node:assert/strict against built dist/ output):

  • tests/fixtures/provider-fixture/ is a minimal community plugin provider: a package.json with a bare openeveProvider export plus a deliberate no-provider entry for error paths. Model your package (and its tests) on it.
  • tests/provider-registry.test.mjs shows the registration contract tests worth having: adapter(kind, opts, { package }) records packageName; your openeveProvider metadata matches what you document; resolveProvider constructs your adapter with merged options and env; the compiler stamps your requiredEnv into preflight; and the exact error messages for missing/misshapen packages.
  • tests/connection-plugins.test.mjs shows connection-plugin contract tests: every catalog entry's helper exists, openevePlugin.connections matches, generated definitions carry the expected transport/URL/subject, and read/write classification behaves for representative tool names.
  • tests/adapters.test.mjs shows channel-module tests: a custom channel with ingress.requiredSecretEnv and resolveAttachment compiled and exercised end-to-end with zero runtime edits, plus the cross-channel credential trust-boundary test (a forged attachment must never reach your resolver with credentials attached).
  • tests/state-stores.test.mjs shows facet tests: a runs-only StateStores boots with one state.degraded warning; a monolithic adapter is detected with none; a missing runs facet throws.
  • Inject fakes through the seams the contracts already provide: ctx.fetch for HTTP, RunDeployCommand for deploy CLIs, provider client injection for sandboxes.

A standalone community package can start with a shape test plus a compile test against a fixture agent:

import assert from "node:assert/strict";

const imported = await import("@yourscope/openeve-acme");

// The plugin contract the CLI and runtime rely on.
assert.equal(typeof imported.defineAcmeConnection, "function");
assert.equal(imported.openevePlugin.connections[0].kind, "acme");

// The helper builds a read-only definition from the explicit policy.
const definition = imported.defineAcmeConnection({
access: { read: true, write: false }
});
assert.equal(definition.access.write, false);
assert.ok(definition.access.read.tools.length > 0);

For provider contributions, compile a fixture agent whose config selects your package with adapter("<kind>", {}, { package: "@yourscope/openeve-acme" }) and assert the manifest carries your requiredEnv in preflight.

Document required/optional env, verification behavior, idempotency keys, and preflight requirements alongside the package — Contributing has the checklist.

  • Plugins — the user-facing plugin model, catalog, and assembly-line add.
  • Adapters — consuming the adapters that ship in this repo.
  • connections/ — the connection file format agents author.
  • Contributing — repo conventions and the plugin-provider checklist.