This document describes Minsky's deployment-platform abstraction: how a project declares its deployment target, the interface a platform adapter implements, and the platform-neutral MCP tools agents call to observe deployments.
It is the reference for adapter authors. Railway is the v1 concrete adapter; the same interface accepts Vercel, Cloudflare Pages, AWS Amplify, Fly.io, etc.
Tracking task: mt#1730.
Minsky integrates with whatever deployment platform a project uses. The repo
this document lives in uses Railway, but Minsky is a project-framework tool and
the next project it lands in may use a different platform. Locking the
agent-facing surface to Railway-specific names (railway_logs, etc.) at v1 would
force a breaking rename when the second platform arrives.
The cost of designing platform-neutrally at v1 is one indirection level in the
adapter (registry lookup at tool call time). The benefit is that adding Vercel
or Cloudflare to a project is one file (services/<svc>/deploy.config.ts) plus
one adapter registration.
The pattern mirrors:
session_pr_wait-for-review— encapsulates GitHub's webhook plumbing. Same shape for any Git host.TaskBackend— Markdown, JSON, GitHub Issues all implement the same domain interface.PersistenceProvider— SQL, in-memory, etc. all behind the same capability interface.
Each service declares its deployment target in services/<svc>/deploy.config.ts.
The file is platform-agnostic in name and content. The platform field is a
discriminator; the rest of the file is the platform-specific config.
// services/minsky-mcp/deploy.config.ts
import { defineDeployment } from "@minsky/shared/deployment-config";
export default defineDeployment({
platform: "railway",
railway: {
projectId: "0e054318-7e19-4489-8e1e-de787965161d",
environmentId: "0289b171-1514-4540-ac93-19b30da3e2c0",
serviceId: "a7c5195f-55de-472a-87e4-34e921a15171",
},
});For Railway services that already declare these IDs in railway.config.ts
(the env-var synthesizer from mt#1437), the railway block can re-import them
to avoid duplication:
import { defineDeployment } from "@minsky/shared/deployment-config";
import railwayConfig from "./railway.config";
export default defineDeployment({
platform: "railway",
railway: {
projectId: railwayConfig.projectId,
environmentId: railwayConfig.environmentId,
serviceId: railwayConfig.serviceId,
},
});A hypothetical Vercel adapter would declare:
// services/<svc>/deploy.config.ts
import { defineDeployment } from "@minsky/shared/deployment-config";
export default defineDeployment({
platform: "vercel",
vercel: {
projectId: "...",
teamId: "...",
},
});A Minsky project may have multiple services on different platforms (e.g., a
Vercel-hosted SPA + a Railway-hosted API). Service-level config accommodates the
multi-platform case without restructuring. This repo now has several declared
services (minsky-mcp, reviewer, cockpit, site, minsky-ops); the file
exists alongside the existing railway.config.ts rather than replacing it.
MCP tools accept an optional service argument. Resolution (mt#2821 extends
step 3):
- If
serviceis passed, look upservices/<service>/deploy.config.ts. - If
serviceis omitted AND the project has exactly onedeploy.config.ts, use it. - If
serviceis omitted AND the project has multipledeploy.config.tsfiles, try two fallbacks before giving up:- a. A configured default: the
deployment.defaultServiceconfig key (minsky config set deployment.defaultService <name>— reuses the existing config system, not a new subsystem). - b. Unambiguous runtime inference: if the process is itself running
inside a Railway container, Railway's own
RAILWAY_SERVICE_IDsystem variable is matched against each candidate's declaredrailway.serviceId. This is a platform ground-truth signal (not a guess) and naturally no-ops outside a Railway container. - If neither resolves, return a typed error (
AmbiguousDeploymentServiceError) listing every available service name.
- a. A configured default: the
// src/domain/deployment/types.ts
export type PlatformName = "railway"; // v1 — extended by future adapters
export type DeploymentStatus =
| "BUILDING"
| "DEPLOYING"
| "SUCCESS"
| "FAILED"
| "CANCELLED"
| "CRASHED"
| "UNKNOWN";
export interface DeploymentRecord {
id: string;
status: DeploymentStatus;
commitHash: string | null;
commitMessage: string | null;
createdAt: string; // ISO8601
finishedAt: string | null;
durationMs: number | null;
url: string | null; // deployment-specific URL if the platform exposes one
}
export type LogType = "build" | "deploy";
export interface LogLine {
timestamp: string;
severity: string; // platform-specific; "info" / "warn" / "error" common
message: string;
}
export interface WaitForLatestOptions {
/** Maximum time to block before giving up. Default 600s (10 min). */
timeoutSeconds?: number;
/** Poll interval. Default 10s. Adapter may ignore if it has a stream primitive. */
pollIntervalSeconds?: number;
}
export interface DeploymentPlatformAdapter {
/**
* Block until the latest deployment for the configured service reaches a
* terminal state (SUCCESS / FAILED / CANCELLED / CRASHED). Returns the
* final record. Throws on timeout.
*/
waitForLatestDeployment(options?: WaitForLatestOptions): Promise<DeploymentRecord>;
/**
* Read-only snapshot of the latest deployment. Does not block.
*/
getLatestDeploymentStatus(): Promise<DeploymentRecord>;
/**
* Fetch logs for a specific deployment.
*/
getDeploymentLogs(deploymentId: string, type: LogType, lines?: number): Promise<LogLine[]>;
}Each platform's native status set normalizes to the DeploymentStatus union.
Railway's SUCCESS / FAILED / CRASHED / BUILDING / DEPLOYING / ERROR maps as:
| Railway status | Normalized |
|---|---|
SUCCESS |
SUCCESS |
FAILED |
FAILED |
CRASHED |
CRASHED |
BUILDING |
BUILDING |
DEPLOYING |
DEPLOYING |
INITIALIZING |
BUILDING |
WAITING |
BUILDING |
REMOVED |
CANCELLED |
ERROR |
FAILED |
| other | UNKNOWN |
Adapters for other platforms map similarly. The agent-facing surface stays neutral.
getDeploymentLogs is block-and-return only. Streaming (follow: true) requires
an out-of-band notification path to deliver chunks to the agent's conversation
context; that's mt#1725's scope. v1 returns the last N lines (or all available)
in a single response.
// src/domain/deployment/registry.ts
export type AdapterFactory = (config: DeploymentConfig) => DeploymentPlatformAdapter;
const adapters = new Map<PlatformName, AdapterFactory>();
export function registerAdapter(name: PlatformName, factory: AdapterFactory): void {
adapters.set(name, factory);
}
export function resolveAdapter(config: DeploymentConfig): DeploymentPlatformAdapter {
const factory = adapters.get(config.platform);
if (!factory) {
throw new UnknownDeploymentPlatformError(config.platform, Array.from(adapters.keys()));
}
return factory(config);
}
export class UnknownDeploymentPlatformError extends Error {
constructor(
public readonly platform: string,
public readonly registered: string[]
) {
super(
`Unknown deployment platform "${platform}". Registered: [${registered.join(", ") || "(none)"}]. ` +
`Check services/<svc>/deploy.config.ts and ensure the adapter is registered.`
);
this.name = "UnknownDeploymentPlatformError";
}
}Adapters self-register at module load. The v1 RailwayDeploymentAdapter module's
top level calls registerAdapter("railway", ...). No fallback or default
platform — explicit declaration in deploy.config.ts is required.
Three platform-neutral tools, registered via the shared command registry.
Naming convention. Commands are registered with hierarchical dotted IDs
(e.g., deployment.wait-for-latest); the MCP bridge converts dots to
underscores on the wire (dashes are preserved). So the registry entry
deployment.wait-for-latest is invoked by agents as
mcp__minsky__deployment_wait-for-latest. This mirrors the existing pattern
(session.pr.wait-for-review → mcp__minsky__session_pr_wait-for-review).
The tool names below are the wire-form agents see.
deployment_wait-for-latest(service?: string, timeoutSeconds?: number) -> DeploymentRecord
Blocks until the latest deployment for the (configured or specified) service
reaches a terminal state. Returns the final record. Throws on timeout. Throws
UnknownDeploymentPlatformError if the service's declared platform is
unregistered.
deployment_status(service?: string) -> DeploymentRecord
Read-only snapshot of the latest deployment. Does not block.
deployment_logs(deploymentId: string, type?: "build" | "deploy", lines?: number, service?: string) -> { lines: LogLine[] }
Fetches logs for the specified deployment. Defaults: type = "build",
lines = 100.
The existing scripts/railway/{lib,status,logs}.ts already implement direct
Railway GraphQL against https://backboard.railway.com/graphql/v2 with auth
from ~/.railway/config.json. The v1 adapter extracts and reuses these
primitives:
readRailwayToken(auth) — reused as-is.graphql<T>helper — moves tosrc/domain/deployment/railway/graphql-client.ts.SERVICE_DEPLOYMENTS_QUERY(fromscripts/railway/status.ts) — reused forgetLatestDeploymentStatus.DEPLOYMENT_LOGS_QUERY(fromscripts/railway/logs.ts) — reused forgetDeploymentLogs.- New:
waitForLatestDeployment— pollsSERVICE_DEPLOYMENTS_QUERYuntil the first deployment's status is in the terminal set.
The bun-script entry points in scripts/railway/ remain as thin CLI wrappers
over the domain module (no behavior change at the CLI layer; both surfaces share
the same code).
A Railway deployment is terminal when its status is one of SUCCESS, FAILED,
CRASHED, CANCELLED, REMOVED, ERROR. BUILDING, DEPLOYING, INITIALIZING,
WAITING are non-terminal.
Default 10s. Configurable via WaitForLatestOptions.pollIntervalSeconds. The
existing scripts/railway/logs.ts could be reused as a streaming primitive
(railway logs --build <id> streams build logs until terminal) but doing so
requires shelling out to the Railway CLI — out of scope for v1 per the spec.
v1 polls the SERVICE_DEPLOYMENTS_QUERY directly.
To add Vercel support:
- Author the adapter.
src/domain/deployment/vercel/adapter.tsimplementsDeploymentPlatformAdapter. Auth via Vercel access token; deployment data via the REST/v6/deploymentsendpoint. - Register the adapter. At module load, call
registerAdapter("vercel", (config) => new VercelDeploymentAdapter(config.vercel)). - Extend the
PlatformNametype. Add"vercel"to the union insrc/domain/deployment/types.ts. - Document the config shape. Add a section to this doc.
- Update
defineDeployment's schema to accept thevercelconfig block.
No agent-facing change is required. The three MCP tools route to the new adapter
automatically once the service's deploy.config.ts declares platform: "vercel".
packages/domain/src/deployment/deploy-surface.ts answers a different question
than everything above: not "how do I talk to a deployment platform" but "does
this changed file actually trigger a deploy at all, and for which service(s)".
It backs both the pre-merge deploy-verification gate
(.minsky/hooks/require-deploy-verification-before-merge.ts) and the
post-merge deploy watch (session.pr.drive's postMerge mode, which calls
deployment_wait-for-latest above only for the services findAffectedServices
resolves).
The pattern list (DEPLOY_SURFACE_PATTERNS / DEPLOY_SURFACE_SERVICE_MAP) is
derived from — and drift-tested against — the paths: blocks of
.github/workflows/deploy-minsky-mcp.yml and deploy-reviewer.yml (mt#3523).
This document does NOT duplicate that pattern list: the code (with its
module-level doc comments) and deploy-surface-workflow-drift.test.ts are the
source of truth, kept in sync mechanically rather than by two hand-maintained
lists agreeing by convention. See packages/domain/src/deployment/deploy-surface.ts
for the current surface.
Two consequences of the surface worth knowing without opening the code (mt#4013, 2026-08-12):
- Root
src/is a minsky-mcp deploy surface. The root Dockerfile COPYs the wholesrctree into the minsky-mcp image, so a PR touching rootsrc/redeploys minsky-mcp on merge — it therefore requires aDeploy verification:commitment in its body (the same requirementpackages/domain/src/PRs carry) and gets post-merge deploy verification. src/cockpit/counts as minsky-mcp surface, not cockpit. Cockpit web source is a bundled input of the minsky-mcp image; the cockpit SERVICE still deploys only viacockpit-preview.yml(PR previews + close-restores) and is deliberately not a merge-deploy target (mt#3832, mt#3996).
docs/deploy-minsky-railway.md— Railway-specific runbook (deploy, config, env vars). The deployment-platform tools section there points back to this document for the platform-agnostic abstraction.services/minsky-mcp/railway.config.ts— env-var synthesizer manifest (mt#1437). Separate concern from this document; thedeploy.config.tsfile may import IDs from it.- mt#1725 — sibling task: agent-context notification path. Out of scope for v1 of this document; constrains
getDeploymentLogsto block-and-return. feedback_external_system_event_wait_survey— operational-knowledge bridge memory that motivated this design.feedback_event_resumption_toolkit_survey— Minsky-side analog of the toolkit-survey discipline.