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 adefine<X>Connectionhelper agents call inconnections/<kind>.ts.
Contents:
- The Provider Contract (openeveProvider)
- Connection Plugins (openevePlugin)
- Sandbox CLI Tools
- How assembly-line add Reads Your Package
- Bundled Skills
- Channel Modules
- Sandbox Adapters
- Blob Adapters
- Deploy Publishers
- Scheduler Adapters
- State Adapters
- Agent Engines Are Not Plugin Providers
- Capability Tier Or Connection Plugin?
- Publishing To npm
- Testing Your Plugin
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:
| Field | Type | Required | Effect |
|---|---|---|---|
kind | string | yes | The connection kind: the assembly-line add argument and the connections/<kind>.ts filename. |
role | "connection" | yes | Always "connection" for plugin metadata. |
helper | string | yes | Exported 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" | yes | Selects the factory family; each factory rejects mismatched metadata. |
transport | "http" | "stdio" | "relay" | "sandbox" | no | Defaults to Streamable HTTP ("http") when omitted. "sandbox" pairs only with protocol: "cli". |
provider | string | yes | Provider identity stamped into metadata.provider on every generated definition. |
description | string | yes | Default connection description when the agent author passes none. |
command | string | for cli | The sandbox CLI executable name; defineSandboxCliPluginConnection fails without it (unless the author overrides). |
defaultUrl | string | no | Endpoint fallback when neither options.url nor urlEnv supplies one. |
urlEnv | string | no | Env var consulted for the endpoint URL (before defaultUrl). Required env when there is no defaultUrl. |
defaultSpec / specEnv | string | OpenAPI only | OpenAPI specification source fallback and env override. |
defaultBaseUrl / baseUrlEnv | string | OpenAPI only | API base URL fallback and env override. |
tokenEnv | string | no | Env var holding the credential. Default auth sends it as a Bearer token. |
tokenRequired | boolean | no | false makes tokenEnv optional and skips default auth when the var is unset. |
tokenHeader | string | no | Send the tokenEnv value verbatim in this header instead of Bearer authorization (for example x-browser-use-api-key). |
credentialEnv | string | relay only | Env var containing one opaque, device-scoped relay binding; defineRelayMcpPluginConnection fails without it. |
scopes | string[] | no | OAuth scopes advertised for authorization flows. |
subject | "user" | ... | no | Default connection subject (per-user vs environment credential scoping) when the author passes none. |
tools | { allow: [...] } | { block: [...] } | no | Default 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 } | no | Runtime-host restrictions for process-backed or platform-specific plugins; enforced at validation, deploy planning, and boot. |
readToolPatterns | string[] | yes | Tool-name patterns classified as read authority. |
writeToolPatterns | string[] | yes | Tool-name patterns classified as write authority. An empty array makes the plugin read-only. |
skill | string | no | Single bundled skill directory name copied by assembly-line add. |
skills | string[] | no | Multiple bundled skill directory names (use one of skill/skills). |
requiredEnv / optionalEnv | string[] | yes/no | Env vars surfaced by assembly-line add and stamped into preflight. Keep them consistent with the URL/token fields above. |
setup | { kind, name, required, message }[] | no | Human setup steps assembly-line add prints and preflight reports. |
stability | "supported" | "preview" | "planned" | no | Support level surfaced in metadata and manifests. |
packageName | string | yes for community | The 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:
| Metadata | Factory | Produces |
|---|---|---|
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 theurlEnvenvironment variable, elsedefaultUrl; a missing URL throws an error namingurlEnv. OpenAPI resolvesspecandbaseUrlthe same way from their fields. - Auth: unless the author passes an
authdefinition (orauth: false), the factory builds a Bearer-token auth fromtokenEnv— skipped whentokenRequired: false— or, whentokenHeaderis set, sends the env value verbatim in that header instead. - Defaults:
description,subject, and thetoolsfilter fall back to metadata;requireddefaults totrue;metadata.providerandmetadata.pluginare 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 ofargsas 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'svalidateReadCommand/validateWriteCommand). hydratePathslists 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.timeoutMsbounds 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
readtool and onewritetool withreadToolPatterns: ["read"]andwriteToolPatterns: ["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 insetupand 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):
openevePlugin.connections— each entry becomes an installable connection contribution (withpackageNamedefaulted to the installed package).openeveProvider.providers— each registration'smetadatabecomes an installable provider contribution.
Selection rules:
--role <role>picks the matching contribution, or fails withPackage <name> has no <role> provider. Available: <role>:<kind>, ....- Without
--role, a single-role package is unambiguous. A multi-role package fails withPackage <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-installand the package absent, the import fails withFailed 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.mdin the package root (plus any reference files), and include"skills"in the package.jsonfilesarray so npm publishes it. - List the directory names in metadata as
skill: "<name>"orskills: ["<a>", "<b>"]— only listed skills are copied. assembly-line addcopies each listed directory into the agent'sskills/<name>/. An existing directory is never overwritten, and a listed skill without aSKILL.mdis 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:
| Member | Purpose |
|---|---|
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, Telegramupdate_id) as the turn'sidempotencyKeyso retries never start duplicate runs, and return{ kind: "accepted" }before model work for providers that enforce fast ACK deadlines. - Verify every request.
normalizeHttpowns signature/token verification. Compare secrets withconstantTimeSecretEqualfrom@assemblyline-agents/runtimeand 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 asingressAuthon the module. The compiler stamps it intoCompiledChannel.metadata.ingress; production boot fails until a group is satisfied, dev mode warns. - Resolve your own attachments.
resolveAttachmentreturns{ 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. Returnundefinedto 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. UseattachmentTrustedForProvider(attachment, "<provider>")andattachmentRemoteUrl(attachment)from@assemblyline-agents/runtimebefore attaching credentials. - Delivery results. A
sendfailure 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 persistedpayload.deliverytarget. - Outbound HTTP. Use the shared runtime HTTP client
(
fetchWithPolicy/fetchJsonfrom@assemblyline-agents/runtime) for provider API calls: per-attempt timeouts,Retry-Afterhandling, backoff with jitter, and size-capped bodies come for free, and thefetchImplparameter preserves thectx.fetchinjection 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 withsandboxFilesystemContractProbeCommand()andassertSandboxFilesystemContractProbe(). - Stamp
sandboxFilesystemContractMetadata()into provider labels/tags and runtime manifests, filter provider lookup by that metadata, requirehasCurrentSandboxFilesystemContract()before reconnect, and includesandboxFilesystemContractKey(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/core—normalizeSandboxPath,normalizeSandboxRoot,resolveSandboxPath(rejects..traversal and retired/runtime),canonicalizeSandboxListingPath,assertCanonicalSandboxWorkingDirectory, andshellQuote— 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
deletePathwhen 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
RunStoremethods unlock durability features:leaseDueDeliveries,completeDeliverySend,failDeliverySend, andrecoverExpiredDeliveriesenable the durable delivery queue (make the lease multi-replica safe — Postgres usesfor update skip locked);listRunsByStatusmakes orphan sweeps efficient;touchRungives the run heartbeat a guarded write that bumpsupdatedAtonly while the run is stillcreated/running— implement it with a single conditional statement (never read-modify-write) so a heartbeat can never race a status transition;requestRunControlandsettleRunControlmust atomically apply cooperative suspend/cancel with cancel precedence across replicas;closeparticipates in graceful shutdown. - Optional facets:
ConversationStore,ScheduleStateStore,FileIndexStore,UsageStore,SandboxSessionStore,MemoryStateStore,RuntimeSettingsStore. Hand them to the runtime as aStateStoresobject ({ runs, conversations?, schedules?, files?, usage?, sandboxSessions?, memory?, settings? }); missing facets fall back to in-memory implementations with a singlestate.degradedboot warning.StateAdapterremains the backward-compatible historical monolith;ResolvedStateAdapteris 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 anexportsentry resolving to your built output, and exportopenevePlugin/openeveProviderfrom that entry (a default export containing them also works). - Depend on
@assemblyline-agents/coreas a peer dependency so your metadata and definition types come from the host's single core instance. - Ship your skills. Include
"skills"(and yourdist) in the package.jsonfilesarray. - Artifact packaging is automatic. The compiled
.assembly-line/package.jsondeclares 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*— sonpm install --omit=devin 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: apackage.jsonwith a bareopeneveProviderexport plus a deliberateno-providerentry for error paths. Model your package (and its tests) on it.tests/provider-registry.test.mjsshows the registration contract tests worth having:adapter(kind, opts, { package })recordspackageName; youropeneveProvidermetadata matches what you document;resolveProviderconstructs your adapter with merged options and env; the compiler stamps yourrequiredEnvinto preflight; and the exact error messages for missing/misshapen packages.tests/connection-plugins.test.mjsshows connection-plugin contract tests: every catalog entry's helper exists,openevePlugin.connectionsmatches, generated definitions carry the expected transport/URL/subject, and read/write classification behaves for representative tool names.tests/adapters.test.mjsshows channel-module tests: a custom channel withingress.requiredSecretEnvandresolveAttachmentcompiled 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.mjsshows facet tests: a runs-onlyStateStoresboots with onestate.degradedwarning; a monolithic adapter is detected with none; a missingrunsfacet throws.- Inject fakes through the seams the contracts already provide:
ctx.fetchfor HTTP,RunDeployCommandfor 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.
Related Docs
- 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.