OpenWOP openwop.dev

Status: Stable · v1.1 (2026-05-12). Promoted DRAFT → FINAL after Phase B audit confirmed all 14 host. capability sections are internally consistent + RFC 2119-clean + cross-linked to capabilities.md §"runtimeCapabilities" + node-packs.md §"Manifest format" peerDependencies. Normative contracts for the host. capabilities that node-pack peerDependencies may declare. A pack that declares peerDependencies: { "host.canvas": "supported" } consumes the canvas surface defined here; the host that advertises host.canvas: supported in /.well-known/openwop MUST expose the contract specified in §host.canvas. Keywords MUST, SHOULD, MAY follow RFC 2119. See auth.md for the status legend.


Why this exists

node-packs.md §"Manifest format" allows packs to declare peerDependencies against engine-supplied capabilities. host-extensions.md documents the namespace rule that host.* is reserved for host-extension capabilities. Neither document specifies the contracts those capabilities expose.

This document does. Each §host.<name> section below defines a normative ctx surface — the methods, signatures, return shapes, and failure modes that any host advertising the capability MUST implement.

External hosts implementing openwop use this document to know exactly what to wire up. Pack authors use it to know what's safe to call. The conformance suite tests against it (gated per-capability via the corresponding profile).


The contract pattern

Every host.* capability follows the same wire pattern:

1. Discovery. The host advertises host.<name>: { supported: true, ... } in /.well-known/openwop's agents block or top-level (per capabilities.md §"Network-handshake shape" extension rules). 2. Registration. The pack registry refuses to register a pack at workflow-register time if the host doesn't advertise the pack's declared peerDependencies capabilities. 3. Dispatch. At node execution, the executor reads from ctx.<name>.<method>(...). The host has wired the named property to a method that satisfies this spec. 4. Failure. If ctx.<name> is absent (host advertised but didn't wire), or the named method is missing, executors MUST throw with error.code = "host_capability_missing" and error.capability = "host.<name>" (or "host.<name>.<method>" for granular misses).

Method signatures use TypeScript-flavor shapes; concrete hosts MAY return additional fields (additive). The required field set is what's listed below.


§host.aiProviders

Capability flag: aiProviders: supported _(advertised via top-level Capabilities.aiProviders.supported[]; see capabilities.md §aiProviders)_

Used by: core.openwop.ai, vendor.myndhyve.ai, vendor.myndhyve.ads-copy-generate, vendor.myndhyve.landing-page, vendor.myndhyve.market-intel-* (all single-AI-call packs).

The lower-level escape hatch for AI invocation. Packs that need raw model output (untyped text + optional structured-output schema) call ctx.callAI directly. The typed-envelope companion is §host.aiEnvelope.

ctx.callAI({
  provider?: string,             // anthropic | openai | google | gemini | ...; defaults to host's preferred routing
  model?: string,                // model id; host-default when omitted
  systemPrompt?: string,
  // RFC 0091 — `content` is `string | ContentPart[]`. A plain string is text-only
  // (today's behavior, always valid). A ContentPart[] carries multimodal PERCEPTION
  // input — `{type:'text',text}` | `{type:'image'|'audio'|'document', mimeType, url? |
  // mediaRef? | data?}` (exactly one source). Non-text parts are gated on
  // `aiProviders.input.modalities`; an unadvertised modality MUST be rejected with
  // `unsupported_modality`. Non-text input is UNTRUSTED (it can carry injected
  // instructions) and inherits the threat-model-prompt-injection.md boundary.
  messages: Array<{ role: 'user' | 'assistant' | 'system', content: string | ContentPart[] }>,
  temperature?: number,          // 0..2
  maxTokens?: number,            // upper bound; host MAY cap further
  stopSequences?: string[],
  responseSchema?: object,       // JSON Schema for structured-output mode (host routes to a provider that supports it)
}) → Promise<{
  content?: string,              // primary text output (omit when only `data` is set)
  data?: object,                 // parsed structured output when `responseSchema` was supplied
  usage?: {
    inputTokens?: number,        // also accepted as `promptTokens` for back-compat with provider conventions
    outputTokens?: number,       // also accepted as `completionTokens`
    totalTokens?: number,
  },
  finishReason?: string,         // 'stop' | 'length' | 'content_filter' | 'tool_calls' | ...
  model?: string,                // model id the host actually routed to (may differ from `model` request when host applies a fallback)
}>

Required methods: callAI.

Optional sub-capabilities:

FlagAddsUsed by
aiProviders.toolCalling: supportedctx.callAIWithTools(...) — model may emit tool_call entriescore.openwop.ai (core.ai.toolCalling)
aiProviders.embeddings: supportedctx.callAI({ embeddingMode: true, dimensions?: number }) returns { embedding: number[], dimensions, model }core.openwop.ai (core.openwop.ai.embeddings)
aiProviders.imageGeneration: supportedctx.callImageGenerator(...) — generates binary image asset (returns URL or base64 data); see optional method block belowvendor.myndhyve.ads-image-generate
aiProviders.videoGeneration: supportedctx.callVideoGenerator(...) — generates binary video asset (returns URL); see optional method block below. Long-running (typical 30-120s); host hides polling internally.vendor.myndhyve.ads-video-generate
// Available when host advertises `aiProviders.imageGeneration: supported`.
ctx.callImageGenerator({
  provider?: string,             // gemini | openai (dall-e) | stability | ...
  model?: string,                // 'imagen-3' | 'dall-e-3' | ...
  prompt: string,
  negativePrompt?: string,
  width: number,                 // pixels; host MAY cap (typical max 2048)
  height: number,
  count?: number,                // default 1; host MAY cap
  seed?: number,                 // deterministic seed (host-supplied or pack-supplied)
  brandColors?: string[],        // optional hint forwarded to providers that accept brand-color guidance
}) → Promise<{
  images: Array<{
    url?: string,                // host-served URL (preferred for large assets)
    base64?: string,             // inline base64 (smaller assets; host's choice)
    mimeType: string,            // 'image/png' | 'image/jpeg' | 'image/webp'
    width: number,
    height: number,
    seed?: number,
    safetyFiltered: boolean,
    metadata?: { model?: string, generationTimeMs?: number },
  }>,
  filteredCount: number,         // count of images dropped by safety filter
  totalTimeMs?: number,
  usage?: { totalCost?: number },
}>
// Available when host advertises `aiProviders.videoGeneration: supported`.
// Host hides async polling internally — the Promise resolves only when
// the video is finalized OR rejects on terminal failure. Typical
// latency 30-120 seconds; packs MUST honor ctx.signal for abort.
ctx.callVideoGenerator({
  provider?: string,             // google (veo) | runway | pika | ...
  model?: string,                // 'veo-2' | 'gen-3' | ...
  prompt: string,
  negativePrompt?: string,
  width: number,                 // pixels; host MAY cap
  height: number,
  durationSeconds: number,       // target duration; host MAY round to provider's allowed lengths
  includeAudio?: boolean,        // default false
  seed?: number,
  brandColors?: string[],
}) → Promise<{
  video: {
    url: string,                 // host-served URL (videos are too large for inline base64)
    durationSeconds: number,
    width: number,
    height: number,
    mimeType: string,            // 'video/mp4' | 'video/webm'
    fileSizeBytes?: number,
    thumbnailUrl?: string,
    seed?: number,
    safetyFiltered: boolean,
    metadata?: {
      model?: string,
      generationTimeMs?: number,
      frameCount?: number,
      fps?: number,
      codec?: string,
    },
  },
  totalTimeMs?: number,
  usage?: { totalCost?: number },
}>

Failure modes:

  • host_capability_missingctx.callAI absent (workflow-register-time refusal via peerDependencies: { aiProviders: "supported" } is the correct path; runtime check is defense-in-depth)
  • provider_unavailable — provider rejected the call or is unreachable
  • provider_quota_exhausted — BYOK quota / host-side rate limit
  • provider_not_supported — caller requested a provider not in Capabilities.aiProviders.supported[]
  • model_not_supported — model id not allowed for the chosen provider
  • response_schema_invalidresponseSchema malformed (caller fault)
  • content_too_long — request exceeds the model's context window (host SHOULD reject pre-flight when possible)
  • image_generation_failed — sub-capability-specific (ctx.callImageGenerator)
  • image_safety_filtered_all — every requested image was safety-filtered (all → filteredCount: count, images: [])
  • video_generation_failed — sub-capability-specific (ctx.callVideoGenerator)
  • video_safety_filtered — video was safety-filtered (resolves with video.safetyFiltered: true AND a placeholder thumbnail; never throws — packs decide how to surface)
  • video_generation_timeout — long-running job exceeded the host's max wait window (host-configured, typical 5 min). Pack should treat as retryable.
  • video_generation_cancelledctx.signal.aborted fired during the polling loop OR the underlying job was cancelled host-side. Not retryable from the pack's perspective.

Determinism note. ctx.callAI is not deterministic in general (temperature > 0, provider-side seed drift). Replay-aware hosts MAY snapshot the AI response in the run event log and replay deterministically; the contract here doesn't require it. See replay.md §"AI determinism".


§host.aiEnvelope

Capability flag: host.aiEnvelope: supported

Used by: vendor.myndhyve.ai, vendor.myndhyve.brand, vendor.myndhyve.web-research

Generates a typed envelope from an LLM call. Routes the call through the host's BYOK provider layer (see host.aiEnvelope vs the lower-level aiProviders capability — this surface is opinionated about envelope shape, that one returns raw model output).

ctx.aiEnvelope.generate({
  systemPrompt: string,
  envelopeType: string,        // e.g., "prd.create", "theme.create"
  provider?: string,           // anthropic | openai | google | ...
  model?: string,
  temperature?: number,
  maxTokens?: number,
  userMessage?: string,
  variables?: Record<string, unknown>,
  context?: Record<string, unknown>,
  idempotencyKey: string,
}) → Promise<{
  envelopeType: string,
  payload: Record<string, unknown>,     // envelope-typed payload
  envelopeId: string,
  usage?: { inputTokens: number, outputTokens: number },
  model?: string,
}>

ctx.aiEnvelope.await({
  envelopeType: string,
  timeoutMs: number,
}) → Promise<{
  envelopeType: string,
  payload?: Record<string, unknown>,
  envelopeId?: string,
  timedOut?: boolean,
}>

Required methods: generate. await is required only when the host advertises host.aiEnvelope.await: supported.

Failure modes:

  • host_capability_missingctx.aiEnvelope absent
  • provider_unavailable — provider rejected or unreachable
  • provider_quota_exhausted — BYOK quota / rate limit
  • envelope_validation_failed — provider returned non-matching shape after retries

Relationship to spec/v1/ai-envelope.md (DRAFT v1.x). The generate() return shape above is a projection of the full AIEnvelope document defined in ai-envelope.md — it surfaces the fields a node pack typically consumes (envelopeType, payload, envelopeId, usage, model) without obliging packs to handle every envelope-level concern (correlationId, meta.source, meta.contentTrust, partial). When the host accepts the emission for engine processing per ai-envelope.md §"Production flow," it wraps the projection back into a full envelope before applying validation, contract gating, redaction, and dedup. A future v1.x evolution MAY widen the projection to surface additional envelope-level fields to packs that opt in; the current narrow shape is preserved for backward compatibility with packs written against pre-DRAFT-v1.x hosts.

A2UI surface support (RFC 0102)

A host that renders agent-authored A2UI surfaces advertises the optional, advertised kind ui.a2ui-surface (ai-envelope.md §"A2UI surfaces") exactly as it advertises any other advertised envelope kind — there is no separate host.a2ui capability block:

  • It lists ui.a2ui-surface in Capabilities.supportedEnvelopes, and gives it a Capabilities.schemaVersions["ui.a2ui-surface"] entry.
  • The supported A2UI catalog versions and the day-1 component allowlist are carried by the per-kind schema itself, not by a separate capability field: the catalogVersion enum in schemas/envelopes/ui.a2ui-surface.schema.json is the supported-version set, and the closed surface anyOf is the component allowlist. Discovery stays single-sourced.
  • A host SHOULD advertise ui.a2ui-surface only when its renderer actually renders the enumerated catalog versions — capability honesty; a host running with OPENWOP_REQUIRE_BEHAVIOR=true fails a dishonest advertisement.

ui.a2ui-surface is not a MUST-recognize universal kind: a host that does not render A2UI simply omits it from supportedEnvelopes, and a consumer receiving an unrecognized ui.a2ui-surface falls back to store-without-render (it MUST NOT fail the run). Requires host.aiEnvelope: supported.


Model-capability declarations

Added by RFC 0031 (Active 2026-05-20). Normates how hosts dispatch envelope-emitting NodeModules whose execution depends on specific model capabilities (structured-output, discriminator-enum, long-context, reasoning, function-calling, or x-host-<host>-* extensions).

NodeModules MAY declare model-capability requirements via NodeModule.requiredModelCapabilities[] + an optional NodeModule.fallbackModel per node-packs.md §"Model-capability declarations on NodeModules." This is a parallel surface to NodeModule.requires[]requires gates on HOST capabilities (e.g., chat.sendPrompt, secrets.byok); requiredModelCapabilities gates on MODEL capabilities advertised at capabilities.modelCapabilities.advertised[].

Dispatch flow (normative)

When dispatching a NodeModule that declares requiredModelCapabilities, a host that advertises capabilities.modelCapabilities.supported: true SHALL:

1. Check the active model's advertised capabilities against the NodeModule's requiredModelCapabilities[]. 2. All required capabilities met → dispatch normally. 3. Unmet AND fallbackModel declared AND host can authenticate to the fallback provider (i.e., the fallback's provider is in capabilities.aiProviders.supported[] AND a credential is resolvable AND the host advertises capabilities.modelCapabilities.substitutionSupported: true): - Substitute the active model with the fallback. - Emit model.capability.substituted per the payload contract in schemas/run-event-payloads.schema.json §modelCapabilitySubstituted. - Dispatch with the fallback model. 4. Unmet AND (no fallbackModel declared, OR substitution not supported, OR host cannot authenticate to the fallback): - Emit model.capability.insufficient per modelCapabilityInsufficient payload. - Refuse to dispatch the node; terminate the run with RunSnapshot.error.code = "capability_not_provided" per capabilities.md §"Unsupported capability — refusal contract."

The ordering MUST be: capability check → optional substitution → emit telemetry → dispatch or refuse. Hosts MUST NOT substitute silently (no event emission); hosts MUST NOT dispatch with an unsuitable model and hope for the best (the model's runtime failure is a worse signal than refusing up-front).

Recursive substitution is NOT permitted (RFC 0031 §"Unresolved questions" #3). A host that substitutes from model A to fallback model B MUST evaluate B's full capability set before dispatching; if B also fails the check, the host MUST emit model.capability.insufficient with fallbackAttempted: true and refuse — it MUST NOT chain to another fallback.

Capability identifier registry

Spec-reserved identifiers (RFC 0031 §C):

IdentifierMeaning
structured-outputVendor strict-mode JSON Schema support (Anthropic strict tool use strict: true, OpenAI strict mode response_format.json_schema.strict: true, Gemini responseSchema on generateContent).
discriminator-enumSingle-string enum: ["literal"] discriminator support in anyOf branches per ai-envelope.md §"Variant payload discrimination (normative)." All three Tier-1 vendors support this when their respective strict modes are engaged.
long-contextContext window ≥ 200k tokens.
reasoningNative reasoning / thinking-tokens (Anthropic extended thinking, Gemini thinkingBudget, OpenAI o-series reasoning). Sibling concept to the RFC 0030 envelope-payload reasoning field — this identifier means _model-native_ thinking-tokens, NOT envelope-payload chain-of-thought.
function-callingMulti-turn function-calling / tool-use loop support.

Host-private extensions MUST prefix with x-host-<host>-<key> per host-extensions.md §"Canonical-prefix table." A future RFC MAY add new spec-reserved identifiers.

Interaction with prompt resolution (RFC 0029)

requiredModelCapabilities and the four-layer prompt-resolution chain (RFC 0029) are orthogonal axes. When a host implements both, the recommended ordering is: capability check first, then prompt resolution. Rationale: substitution may swap models with different prompt-tuning expectations; resolving prompts against the _original_ model when dispatch ends up using the _fallback_ is incorrect. The model.capability.substituted event (this RFC) and agent.promptResolved (RFC 0029) MAY both fire for the same node execution; no precedence rule applies between them at the protocol level.


§host.promptLibrary

Capability flag: host.promptLibrary: supported

Used by: vendor.myndhyve.ai (specifically core.ai.callPrompt)

Looks up a prompt by ID. Pin to a specific version for replay determinism.

ctx.promptLibrary.get(promptId: string) → Promise<{
  promptId: string,
  systemPrompt: string,
  version: string,
  envelopeType?: string,        // when the prompt is bound to a specific envelope
}>

Required methods: get.

Failure modes:

  • host_capability_missingctx.promptLibrary absent
  • prompt_not_found — promptId doesn't resolve
  • prompt_version_pinned — pin requested but not retrievable

§host.canvas

Capability flag: host.canvas: supported

Used by: vendor.myndhyve.canvas

Reads + writes canvas state. Routes through host's canvas store (typically Firestore on MyndHyve; arbitrary on other hosts).

ctx.canvas.read({
  canvasId: string,
  paths?: string[],             // optional jsonpath-like field selection
}) → Promise<{
  canvasId: string,
  canvasTypeId: string,
  state: Record<string, unknown>,
  version: string,
}>

ctx.canvas.write({
  canvasId: string,
  patch: Record<string, unknown>,
  patchType: 'merge' | 'replace',
  idempotencyKey: string,
}) → Promise<{
  canvasId: string,
  version: string,
  appliedAt: string,            // ISO 8601
}>

ctx.canvas.create({
  canvasTypeId: string,
  workspaceId: string,
  projectId?: string,
  initialState?: Record<string, unknown>,
  idempotencyKey: string,
}) → Promise<{
  canvasId: string,
  canvasTypeId: string,
  createdAt: string,
}>

ctx.canvas.crossInvoke({
  sourceCanvasId: string,
  targetCanvasTypeId: string,
  message: Record<string, unknown>,
  idempotencyKey: string,
}) → Promise<{
  invocationId: string,
  targetCanvasId?: string,       // when target is single-canvas
  acceptedAt: string,
}>

Required methods: read, write. create and crossInvoke are required only when the host advertises host.canvas.create: supported / host.canvas.crossInvoke: supported.

Failure modes:

  • host_capability_missingctx.canvas absent
  • canvas_not_found — canvasId doesn't resolve
  • canvas_permission_denied — caller lacks read/write permission
  • canvas_version_conflict — optimistic-concurrency conflict
  • cross_canvas_circuit_open — target canvas type circuit-broken

§host.artifactTypes

Capability flag: host.artifactTypes: { supported, store, render, export[], types? }

Used by: artifact-type packs (kind: "artifact-type", RFC 0071; amended by RFC 0075 — see artifact-type-packs.md).

Unlike most host.* capabilities, this one adds **no ctx.artifactTypes.* method**: it is an advertisement that changes how the host treats the artifact references already on the wire (nodes[].artifact.typeId, WorkflowNode.artifactType, artifact.created.artifactType). The facets are negotiated together, not dispatched independently:

"host.artifactTypes": {
  "supported": true,
  "store":  true,           // persists registered artifacts + emits artifact.created
  "render": false,          // advisory; the spec defines no rendering surface
  "export": ["pdf"]         // export-format identifiers the host can materialize
}

Behavior (normative):

  • When supported, the host validates an artifact whose type matches a registered type — either an installed pack's artifactTypeId (registrationSource: "pack") or a host-native type it validates against a host-known schema (registrationSource: "host", RFC 0075) — against that type's schema before emitting artifact.created (and sets registered: true + registrationSource); unregistered types are accepted unvalidated with registered: false (the permanent first-class tier). See artifact-type-packs.md §"Binding the existing artifact surfaces".
  • A host advertising store: true MUST persist registered artifacts and emit artifact.created.
  • A host advertising render: false for a type it can store MUST still accept and store the artifact and MUST NOT fail the run for lack of a renderer — the cross-host store-without-render negotiation guarantee.
  • The host MUST bound third-party schema compilation per artifact-type-packs.md §"Bounded schema compilation" (artifact-schema-compile-bounded invariant).

Per-type facets (types, RFC 0075). Capability is per-type. A host MAY declare a types map keyed by artifactTypeId; each entry overrides the global object and carries { validated, validation, schemaVersion, store, render, export }. The global object is the fallback for any type not listed (types absent ⇒ host-global semantics; any facet absent ⇒ the global default — additive).

"host.artifactTypes": {
  "supported": true, "store": true, "render": true, "export": ["pdf"],
  "types": {
    "vendor.myndhyve.prd":   { "validated": true,  "validation": "open",   "schemaVersion": 1, "render": true },
    "vendor.acme.cad.model": { "validated": false, "store": true, "render": false }
  }
}
  • validated (RFC 0075) is the runtime validation guarantee (true ⇒ the host validates this type before emit, so emits registered: true). schemaVersions["…"] is only a version _declaration_; the two are decoupled so a host can declare known versions without obligating per-type validation.
  • validation ("open"/"closed") mirrors ArtifactType.validation, surfacing schema strictness in discovery so a consumer needn't fetch the schema to know whether to expect a closed-world shape (default "open" per COMPATIBILITY.md §2.1).
  • A host emitting registered: true for a host-native (no-pack) type MUST serve its canonical schema URL (artifact-type-packs.md §"Schema distribution", P1-3) so the claim is downstream-verifiable.

A pack MAY declare peerDependencies: { "host.artifactTypes": "supported" }; the registry refuses registration against a host that does not advertise it.


§host.chat

Capability flag: host.chat: supported

Used by: vendor.myndhyve.chat

Posts messages + cards into a chat session. The session is established by the host; the pack receives ctx.chat.sessionId (or scopes via config).

ctx.chat.sendMessage({
  role: 'agent' | 'user' | 'system',
  content: string,
  citations?: Array<{ url: string, title?: string }>,
  sessionId?: string,
  idempotencyKey: string,
}) → Promise<{
  messageId: string,
  sentAt: string,
}>

ctx.chat.emitCard({
  cardId: string,
  cardType: string,             // e.g., 'progress' | 'approval' | 'clarification' | 'phase-input'
  payload: Record<string, unknown>,
  idempotencyKey: string,
}) → Promise<{
  cardId: string,
  emittedAt: string,
}>

ctx.chat.updateCard({
  cardId: string,
  patch: Record<string, unknown>,
  patchType: 'merge' | 'replace',
  idempotencyKey: string,
}) → Promise<{
  cardId: string,
  updatedAt: string,
  found: boolean,
}>

Required methods: sendMessage. emitCard + updateCard required when the host advertises host.chat.cards: supported.

host.chat.cardPacks (RFC 0071 Phase 2). An additive sub-flag: a host advertising host.chat.cardPacks: supported resolves registered chat-card definitions (from installed kind: "card" packs — see chat-card-packs.md) and executes them per that doc's §"Card execution": substitute the card's typed inputs into its prompt.template, route through ctx.aiEnvelope.generate, and (when the card declares an outputArtifactType) validate the result against the registered artifact type's schema before emitting artifact.created. A WorkflowNode.cardType (or ctx.chat.emitCard cardType) value SHOULD then reference a registered cardTypeId; existing free-form cardType strings remain valid. Card-input-derived prompt segments are untrusted and MUST propagate meta.contentTrust: "untrusted" per chat-card-packs.md §"Trust boundary". Requires host.aiEnvelope: supported.

Failure modes:

  • host_capability_missingctx.chat absent
  • chat_session_not_found — sessionId doesn't resolve
  • card_not_found — updateCard targets a non-existent cardId (returns found: false instead of throwing)

§host.brand

Capability flag: host.brand: supported

Used by: vendor.myndhyve.brand

State mutation + validation for brand artifacts (themes + personas).

ctx.brand.implementTheme({
  brandId: string,
  payload: Record<string, unknown>,    // theme spec
  targetCanvasId?: string,
  force?: boolean,
  idempotencyKey: string,
}) → Promise<{
  artifactId: string,
  appliedAt?: string,
}>

ctx.brand.publishTheme({
  brandId: string,
  payload: Record<string, unknown>,
  idempotencyKey: string,
}) → Promise<{
  artifactId: string,
  appliedAt?: string,
}>

ctx.brand.publishPersona({
  brandId: string,
  payload: Record<string, unknown>,
  idempotencyKey: string,
}) → Promise<{
  artifactId: string,
  appliedAt?: string,
}>

ctx.brand.validateTheme({
  artifactId: string,
  ruleSet?: string,
  failOnWarning?: boolean,
}) → Promise<{
  rulesRun: number,
  violations: Array<{
    ruleId: string,
    severity: 'error' | 'warning' | 'info',
    message: string,
  }>,
}>

ctx.brand.validatePersona(/* same shape as validateTheme */)
  → Promise<{ rulesRun, violations }>

Required methods: all five.

Failure modes:

  • host_capability_missingctx.brand absent
  • brand_not_found — brandId doesn't resolve
  • theme_invalid_shape — payload fails server-side schema validation

§host.kanban

Capability flag: host.kanban: supported

Used by: vendor.myndhyve.kanban, vendor.myndhyve.launch-studio (shares getReadyTasks + moveTask)

Board + task + timeline + automation operations.

ctx.kanban.boardCreate({
  name: string,
  columns: Array<{ id: string, label: string, ... }>,
  description?: string,
  projectId?: string,
  idempotencyKey: string,
}) → Promise<{ boardId: string, createdAt: string }>

ctx.kanban.boardReview({
  boardId: string,
  includeArchived?: boolean,
  atRiskThresholdDays?: number,
}) → Promise<{
  boardName?: string,
  totalTasks: number,
  columnCounts: Record<string, number>,
  atRiskTasks: Array<{ taskId: string, ... }>,
  reviewedAt?: string,
}>

ctx.kanban.taskAssign({
  taskId: string,
  assigneeId: string,
  notifyAssignee?: boolean,
  comment?: string,
  idempotencyKey: string,
}) → Promise<{
  previousAssigneeId?: string,
  assignedAt?: string,
}>

ctx.kanban.taskGet(taskId: string) → Promise<TaskDetail>         // optional

ctx.kanban.taskCreateBatch({
  parentTaskId: string,
  subtasks: Array<{ title: string, ... }>,
  idempotencyKey: string,
}) → Promise<{ subtaskIds: string[] }>                            // optional

ctx.kanban.timelinePlan({
  boardId: string,
  scheduler: 'critical-path' | 'earliest-start' | string,
  ...
}) → Promise<{
  schedule: Array<{ taskId: string, startAt: string, endAt: string }>,
  criticalPath: string[],
  projectEndDate?: string,
}>

ctx.kanban.automateRules({
  boardId: string,
  rules: Array<{
    trigger: 'column-transition' | 'due-date-approaching' | 'label-changed' | 'assignee-changed',
    action: 'assign' | 'move-column' | 'set-label' | 'send-notification' | 'invoke-workflow',
    config: Record<string, unknown>,
  }>,
  replaceExisting?: boolean,
  idempotencyKey: string,
}) → Promise<{
  activeRules: number,
  added: number,
  appliedAt?: string,
}>

ctx.kanban.resourceMonitor({
  boardId: string,
  maxConcurrentPerAssignee?: number,
  includeAgents?: boolean,
}) → Promise<{
  assigneeLoad: Record<string, number>,
  wipBreaches: Array<{ assigneeId: string, current: number, max: number }>,
  overdueTasks: Array<{ taskId: string, dueAt: string }>,
  monitoredAt?: string,
}>

// Shared with vendor.myndhyve.launch-studio:
ctx.kanban.getReadyTasks(boardId: string) → Promise<Array<{ id: string, ... }>>
ctx.kanban.moveTask(taskId: string, toColumn: string) → Promise<void>

Required methods: boardCreate, boardReview, taskAssign, timelinePlan, automateRules, resourceMonitor, getReadyTasks, moveTask. taskGet, taskCreateBatch are optional.

Failure modes:

  • host_capability_missing
  • board_not_found
  • task_not_found
  • task_transition_invalid — column not in the board's column set

§host.webResearch

Capability flag: host.webResearch: supported

Used by: vendor.myndhyve.web-research

Search + fetch + research orchestration. Routes through host's search adapter (Google CSE, Bing, Brave, Perplexity, Kagi, etc.).

ctx.webResearch.search({
  query: string,
  maxResults?: number,
  engine?: string,
  language?: string,
  region?: string,
  safeSearch?: boolean,
  siteFilter?: string,
}) → Promise<{
  results: Array<{ url: string, title: string, snippet?: string, rank?: number }>,
  engine: string,
  totalResults?: number,
}>

ctx.webResearch.fetchBatch({
  urls: string[],
  concurrency?: number,
  perRequestTimeoutMs?: number,
  respectRobotsTxt?: boolean,
  maxBodyBytes?: number,
  extractReadable?: boolean,
}) → Promise<{
  pages: Array<{
    url: string,
    status: number,
    contentType?: string,
    title?: string,
    extractedText?: string,
    rawBody?: string,
    truncated?: boolean,
    fetchedAt?: string,
    error?: string,
  }>,
}>

ctx.webResearch.research({
  query: string,
  maxResults: number,
  ...filters
}) → Promise<{
  citations: Array<{
    url: string,
    title: string,
    snippet?: string,
    content: string,
    rank?: number,
    fetchedAt?: string,
  }>,
  engine?: string,
  totalResults?: number,
}>

Required methods: search. fetchBatch and research required when host advertises host.webResearch.scraping: supported.

Failure modes:

  • host_capability_missing
  • search_quota_exhausted
  • fetch_blocked_by_robots
  • fetch_timeout

§host.agentRuntime

Capability flag: host.agentRuntime: supported

Used by: vendor.myndhyve.agent-orchestration (the agent.* typeIds)

This is the heavyweight swarm/consensus superset. The minimal tier that makes a published agent pack runnable is capabilities.agents.manifestRuntime (RFC 0070) — loading a pack's agents[] into an AgentRegistry (RFC 0003) and dispatching one on the existing core.dispatch/orchestrator loop. A host advertising host.agentRuntime: supported implies agents.manifestRuntime (RFC 0070 §B), since spawn({ manifestId }) instantiates a manifest agent. Hosts that only need single-agent / crew dispatch advertise the floor and omit this section. A multi-tenant host additionally advertises agents.manifestRuntime.installScope: 'tenant' (RFC 0074) so GET /v1/agents is scoped to the caller's owner triple (default 'host' = host-global, RFC 0072 §A behavior); see node-packs.md §"Inventory scope".

Related: per-tool authorization lives at the top level, not under agents. A host that runs a function-calling loop for manifest agents (the model requesting tools per RFC 0072 §B) advertises per-tool authorization, rate limiting, and the content-free tool-call audit trail via the top-level Capabilities.toolHooks block — see §host.toolHooks — _not_ via a sub-flag of agents next to manifestRuntime. toolHooks is transport- and runtime-agnostic (it covers MCP, HTTP egress, and native tool calls alike), so it is not nested under the agent-runtime tier.

Operates on RFC 0002 / 0003 / 0007 protocol primitives — spawn, delegate, consensus, message-send, skill-invoke, swarm-execute.

ctx.agentRuntime.spawn({
  manifestId?: string,           // RFC 0003 AgentManifest reference
  agentRef?: AgentRef,           // RFC 0002 AgentRef inline definition
  config?: Record<string, unknown>,
  idempotencyKey: string,
}) → Promise<{ agentInstanceId: string, spawnedAt: string }>

ctx.agentRuntime.delegate({
  task: string,
  agents: AgentRef[],
  strategy?: 'best-match' | 'load-balanced',
  escalationThreshold?: number,  // RFC 0007 §F low-confidence threshold
  ...
}) → Promise<{
  selectedAgentId: string,
  delegationId: string,
  escalated?: boolean,
}>

ctx.agentRuntime.consensus({
  proposal: Record<string, unknown>,
  participants: AgentRef[],
  threshold?: number,
  maxRounds?: number,
  ...
}) → Promise<{ converged: boolean, rounds: number, agreement?: unknown }>

ctx.agentRuntime.messageSend({
  fromAgentId: string,
  toAgentId: string,
  channel: 'message' | string,
  content: unknown,
  idempotencyKey: string,
}) → Promise<{ messageId: string, sentAt: string }>

ctx.agentRuntime.skillInvoke({
  agentId: string,
  skillName: string,
  inputs: Record<string, unknown>,
  idempotencyKey: string,
}) → Promise<{ outputs: Record<string, unknown> }>

ctx.agentRuntime.swarmExecute({
  swarmId?: string,
  pattern: 'map' | 'map-reduce' | 'competitive',
  task: Record<string, unknown>,
  agentIds?: string[],
  ...
}) → Promise<{
  results?: Array<unknown>,
  reduced?: unknown,
  winner?: { agentId: string, result: unknown },
  succeeded: number,
  failed: number,
  durationMs: number,
}>

Required methods: all six.

Failure modes:

  • host_capability_missing
  • agent_not_found
  • agent_spawn_quota_exhausted
  • agent_skill_not_found

§host.coordination

Capability flag: host.coordination: supported

Used by: vendor.myndhyve.agent-orchestration (the coordination.* typeIds)

Multi-participant coordination primitives. Distinct from host.agentRuntime — coordination operates on _roles_ (voters, competitors), not individual agent identities. A host MAY implement both surfaces; typical hosts that do implement agentRuntime by delegating to coordination internally.

ctx.coordination.vote({
  question: string,
  options: Array<{ id: string, label: string, value?: unknown } | string>,
  participantIds: string[],
  strategy: 'majority' | 'supermajority' | 'unanimous' | 'plurality' | 'ranked-choice' | 'weighted',
  quorum?: number,           // 0-1
  timeoutMs?: number,
}) → Promise<{
  winningOptionId: string,
  winningValue?: unknown,
  voteCounts: Record<string, number>,
  quorumMet: boolean,
  margin: number,
  voteCount: number,
  success: boolean,
  error?: string,
}>

ctx.coordination.consensus({...}) → Promise<{
  agreement?: unknown,
  converged: boolean,
  rounds: number,
  success: boolean,
}>

ctx.coordination.compete({...}) → Promise<{
  winnerId: string,
  winnerResult: unknown,
  durationMs: number,
  success: boolean,
}>

ctx.coordination.mapReduce({...}) → Promise<{
  results: unknown[],
  reduced?: unknown,
  success: boolean,
}>

ctx.coordination.delegate({...}) → Promise<{
  assigneeId: string,
  matchScore: number,
  success: boolean,
}>

ctx.coordination.roundRobin({
  cursor: number,
  ...
}) → Promise<{
  assigneeId: string,
  cursor: number,
  nextCursor: number,
  success: boolean,
}>

Required methods: vote is required when the capability is advertised. The others are required individually when the host advertises e.g. host.coordination.consensus: supported.

Failure modes:

  • host_capability_missing
  • quorum_not_met — when input requires quorum and vote returned quorumMet: false
  • consensus_max_rounds_exceeded

§host.dataIntegration

Capability flag: host.dataIntegration: supported

Used by: vendor.myndhyve.data-integration

Typed data-source operations. Fetches from configured external sources (REST, GraphQL, A2A peers, MCP), runs transforms, manages run-scoped variables.

ctx.dataIntegration.fetchRest({
  sourceId: string,
  method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH',
  path?: string,
  params?: Record<string, unknown>,
  body?: unknown,
  paginate?: boolean,
  idempotencyKey: string,
}) → Promise<{
  data: unknown,
  status: number,
  pageCount?: number,
}>

ctx.dataIntegration.fetchGraphql({
  sourceId: string,
  query: string,
  variables?: Record<string, unknown>,
  idempotencyKey: string,
}) → Promise<{
  data?: unknown,
  errors?: Array<{ message: string, path?: string[] }>,
}>

ctx.dataIntegration.fetchA2A({
  sourceId: string,
  task: Record<string, unknown>,
  idempotencyKey: string,
}) → Promise<{ result: unknown }>

ctx.dataIntegration.fetchMCP({
  sourceId: string,
  operation: {
    kind: 'tool' | 'resource',
    name: string,
    args?: Record<string, unknown>,
  },
  idempotencyKey: string,
}) → Promise<{ data: unknown, isError?: boolean, mimeType?: string }>

ctx.dataIntegration.transform({
  expression: string,
  language: 'jsonata' | 'jq' | string,
  data: unknown,
}) → Promise<{ result: unknown }>

ctx.dataIntegration.computeVariable({
  variableName: string,
  expression: string,
  language?: string,
  context?: Record<string, unknown>,
}) → Promise<{ value: unknown }>

ctx.dataIntegration.fetchVariable({
  variableName: string,
}) → Promise<{ value?: unknown, found: boolean }>

ctx.dataIntegration.applyBinding({
  targetType: string,
  targetPath: string,
  value: unknown,
  transform?: string,
  fallback?: unknown,
  canvasId?: string,
  updateMode: 'immediate' | 'batched',
  preserveUndo: boolean,
}) → Promise<{
  applied: boolean,
  previousValue?: unknown,
  resolvedValue?: unknown,
}>

Required methods: depends on which sub-capabilities the host advertises. The minimal surface for host.dataIntegration: supported is transform + applyBinding. REST/GraphQL/A2A/MCP fetch methods require the respective sub-capabilities (host.dataIntegration.rest: supported, etc.).

Failure modes:

  • host_capability_missing
  • source_not_found — sourceId doesn't resolve
  • transform_expression_invalid
  • binding_target_not_found

§host.launchStudio

Capability flag: host.launchStudio: supported

Used by: vendor.myndhyve.launch-studio

Launch-studio specific operations. Backbone for the multi-canvas LS workflow.

ctx.launchStudio.getStudio(studioId: string) → Promise<{
  studioId: string,
  brandId?: string,
  designSystemId?: string,
  prdId?: string,
  sharedArtifactRefs: Array<{ artifactId: string, artifactTypeId: string }>,
  steps: Array<{ stepId: string, canvasTypeId: string, projectId?: string }>,
} | null>

ctx.launchStudio.buildProjectContext({
  studio: Studio,
  userId: string,
  canvasTypeId: string,
}) → Promise<Record<string, unknown>>

ctx.launchStudio.resolveLinkedArtifacts({
  studio: Studio,
  userId: string,
  sourceCanvasTypeId: string,
}) → Promise<Record<string, unknown>>

Required methods: all three.

Failure modes:

  • host_capability_missing
  • studio_not_found — getStudio returns null on miss (NOT thrown — convention)
  • studio_permission_denied

§host.entities

Capability flag: host.entities: supported

Used by: vendor.myndhyve.entities

Generic entity CRUD operations. Projects + workspace assets (brand, persona, knowledge bases).

ctx.entities.createProject({
  userId: string,
  name: string,
  canvasTypeId: string,
  type: string,
  status: string,
  settings: Record<string, unknown>,
  idempotencyKey: string,
}) → Promise<{ id: string }>

ctx.entities.listAssets({
  assetType: 'brand' | 'persona' | 'knowledgeBase',
  filterConfig?: Record<string, unknown>,
}) → Promise<Array<{
  id: string,
  name: string,
  description?: string,
  status?: string,
}>>

ctx.entities.getAsset({
  assetType: 'brand' | 'persona' | 'knowledgeBase',
  assetId: string,
}) → Promise<Record<string, unknown> | null>

Required methods: all three.

Failure modes:

  • host_capability_missing
  • entity_permission_denied
  • entity_quota_exceeded

§host.messaging

Capability flag: host.messaging: supported

Used by: vendor.myndhyve.entities

Outbound chat-egress dispatch. The host owns the connector layer (Slack, WhatsApp, SMS, email, etc.); the pack hands off envelopes for dispatch.

ctx.messaging.dispatchEgressEnvelope({
  envelope: {
    type: 'chat.egress',
    version: string,
    channel: string,
    accountId: string,
    delivery: {
      conversationId: string,
      threadId?: string,
      inReplyTo?: string,
    },
    content: {
      text?: string,
      media?: Array<{ kind: string, ref: string }>,
    },
    idempotencyKey: string,
    mode: { typing: boolean, draftStreaming: boolean },
  },
  connectorInstanceId: string,
  nodeId: string,
}) → Promise<{
  success: boolean,
  deliveryId?: string,
  platformMessageId?: string,
  durationMs?: number,
  error?: string,
}>

Required methods: dispatchEgressEnvelope.

Failure modes:

  • host_capability_missing
  • connector_not_found
  • connector_disconnected
  • channel_unsupported

§host.mcp

Capability flag: host.mcp: supported

Used by: core.openwop.mcp

Workflow-author MCP operations. Distinct from host.dataIntegration.fetchMCP — that's MCP-as-data-source; this is MCP-as-tool-runtime.

ctx.mcp.invokeTool({
  serverId: string,
  toolName: string,
  args?: Record<string, unknown>,
  idempotencyKey: string,
}) → Promise<{ result: unknown, isError?: boolean }>

ctx.mcp.listTools({
  serverId: string,
}) → Promise<{
  tools: Array<{ name: string, description?: string, inputSchema: object }>,
}>

ctx.mcp.readResource({
  serverId: string,
  uri: string,
}) → Promise<{
  contents: Array<{ uri: string, mimeType?: string, text?: string, blob?: string }>,
}>

ctx.mcp.serverStatus({
  serverId: string,
}) → Promise<{
  serverId: string,
  status: 'connected' | 'disconnected' | 'error',
  protocolVersion?: string,
  serverInfo?: { name: string, version: string },
}>

Required methods: all four.

Failure modes:

  • host_capability_missing
  • mcp_server_not_found
  • mcp_server_disconnected
  • mcp_tool_not_found
  • mcp_tool_invocation_failed

See also — server direction (RFC 0020). The surface above covers workflows that _call out_ to remote MCP servers. Hosts MAY also advertise the _server-mount_ direction via capabilities.mcp.serverMount: { supported: true, transports, samplingBridge, elicitationBridge } — exposing their workflows as MCP tools, resources, and prompts callable by external MCP-aware LLM clients. See mcp-integration.md §"OpenWOP host as MCP server" for the state-projection table, bidirectional sampling/createMessage and elicitation/create callbacks, trust-boundary discipline, and 6 capability-gated conformance scenarios.


§host.knowledge

Capability flag: host.knowledge: supported

Used by: vendor.myndhyve.knowledge-tools (the knowledge.retrieve + knowledge.augment-prompt typeIds). Composes downstream of core.openwop.ai consumers for RAG-grounded workflows — see examples/rag-grounded-chat/ for the canonical 2-node reference.

Knowledge-base retrieval. Routes queries through the host's RAG pipeline (embedding → vector search → optional re-rank). The host owns the corpus, the embedding model, and the access-control boundary; the pack supplies the query.

ctx.knowledge.retrieve({
  query: string,
  workspaceId?: string,       // omit to use the run's workspace
  collectionIds?: string[],   // scope to specific knowledge collections
  category?: string,          // optional category facet
  candidateLimit?: number,    // pre-rank candidate pool size; host caps the upper bound
  resultLimit?: number,       // post-rank returned chunks; host caps the upper bound
  scoreThreshold?: number,    // minimum relevanceScore (0..1) for inclusion
}) → Promise<{
  chunks: Array<{
    chunkId: string,
    content: string,                // prepared/cleaned chunk text suitable for prompt insertion
    rawContent?: string,            // optional verbatim source text (when distinct from content)
    headingPath: string[],          // section heading trail from the source document
    pageNumber: number | null,
    documentTitle: string,
    assetId: string,                // host-internal id for the source media asset
    collectionId: string,
    relevanceScore: number,         // 0..1 — host-normalized post-rank score
    vectorDistance?: number,        // pre-rank distance; informational only
  }>,
  sources: Array<{
    sourceId: string,               // stable id for citation (de-duplicated across chunks)
    assetId: string,
    title: string,
    headingPath: string[],
    pageNumber: number | null,
  }>,
  latencyMs?: number,
  hasResults: boolean,
}>

Required methods: retrieve.

Optional methods (host MAY advertise host.knowledge.embed: supported to expose them):

ctx.knowledge.embed({
  texts: string[],
  model?: string,                   // host-allowed embedding model alias
}) → Promise<{
  vectors: number[][],              // one row per input; dimension is host-defined and stable per model
  model: string,
  dimension: number,
}>

RBAC:

  • The host MUST enforce that workspaceId is one the calling run has read access to. Cross-workspace retrieval MUST return 403 knowledge_workspace_forbidden.
  • collectionIds[] MUST be filtered to those visible to the caller; chunks from collections the caller cannot read MUST be omitted, NOT errored on.

Determinism:

  • retrieve is NOT pure — corpus and embeddings change over time. Packs SHOULD treat results as an input snapshot for the current run.
  • Hosts SHOULD include enough metadata (chunkId, assetId, headingPath, pageNumber) for packs to render citations stably.

Failure modes:

  • host_capability_missing
  • knowledge_workspace_forbidden — caller cannot read the workspace
  • knowledge_query_too_long — query exceeds host's embedding-model token limit
  • knowledge_quota_exhausted — workspace-level retrieval quota tripped
  • knowledge_collection_not_found — explicit collectionIds[] includes an id that does not exist for this workspace (vs. a no-access filter, which silently skips)

§host.secrets

Capability flag: secrets.resolveInPack: supported _(advertised via top-level Capabilities.secrets; see capabilities.md §secrets)_

Used by: packs that must call external HTTP APIs requiring stored credentials (e.g., ad-platform APIs, third-party analytics endpoints, vendor-specific SaaS integrations). Current consumers: vendor.myndhyve.ads-publish-meta, vendor.myndhyve.ads-publish-google, vendor.myndhyve.ads-publish-tiktok (the 3 platform-publish packs; the ads.publish.platform umbrella decomposed into platform-specific packs during publish).

Resolves an opaque, host-issued secret reference into plaintext inside the pack process, for the narrow case where a pack needs raw credentials to call an external service that the host doesn't proxy. This is the highest-risk host capability in the spec — every related rule below is a hard requirement, not a recommendation.

ctx.secrets.resolve({
  ref: string,                       // opaque host-issued credential reference (e.g., "secret:tenant:meta-ads-api-token:v3")
  purpose: string,                   // free-form audit string — required (logged by host, NOT by pack)
}) → Promise<{
  plaintext: string,                 // raw credential value; consumed and discarded by the pack — NEVER re-emitted
  expiresAt?: string,                // ISO 8601; pack SHOULD treat as advisory and re-resolve before expiry on long-running calls
  rotatedAt?: string,                // ISO 8601 of last rotation (advisory; for caches that key on rotation epoch)
}>

Required methods: resolve. Hosts that advertise secrets.resolveInPack: supported MUST implement this method AND comply with the redaction invariants below.

Hard rules (extending NFR-7 — Sensitive Data Redaction):

The plaintext returned by ctx.secrets.resolve(...) is the most sensitive value flowing through the pack runtime. Hosts AND packs MUST jointly enforce:

RuleOwnerDetail
Plaintext MUST NOT appear in RunEvent payloadsHostEvent emitter MUST redact secrets.resolve outputs from every serialized event (including node.input / node.output / node.error).
Plaintext MUST NOT appear in OTel spans, log lines, or trace exportsHostTracing adapter MUST scrub. Pack runtime MUST NOT log resolved plaintext via ctx.log.
Plaintext MUST NOT appear in RunSnapshot exports or replay snapshotsHostSnapshot serializer MUST redact. Replay determinism is preserved by replaying the _resolve call_, not by snapshotting the plaintext (host resolves freshly from the credential store on replay).
Plaintext MUST NOT be persisted in pack-side caches across run boundariesPackPack MAY cache within a single ctx.callImageGenerator / fetch() call site for that one invocation. After the call, the plaintext reference MUST be discarded.
Plaintext MUST NOT be sent to any ctx.* method other than the consuming call (e.g., fetch)PackSpecifically: never pass to ctx.callAI, ctx.chat.sendMessage, ctx.canvas.write, or any other host method. The resolution is for direct external HTTP only.
purpose field MUST be present and non-emptyPackHost audit log records {ref, purpose, runId, packName, packVersion, ts}purpose is the required audit breadcrumb.
Lint + redaction unit testsHostHosts that advertise this capability MUST add CI checks verifying plaintext never appears in serialized output across the surfaces above.

Determinism note. ctx.secrets.resolve is non-deterministic by design — the host MAY rotate secrets between runs, MAY return different plaintext on the same ref across runs (rotation), AND MUST NOT snapshot plaintext for replay. Replay-aware hosts SHOULD record only the resolve _call site_ (ref + purpose + ts) and re-resolve from the credential store at replay time. Packs that change behavior based on plaintext content (e.g., parsing a JWT to extract a tenant id) MUST treat the resolved value as run-input that may differ across runs.

RBAC. The host MUST enforce that ref resolves only to credentials the calling run has access to. Refs from another workspace's secret namespace MUST fail with secret_access_denied. Hosts MUST NOT silently substitute a different credential if the requested ref is unavailable.

Failure modes:

  • host_capability_missingctx.secrets.resolve absent (workflow-register-time refusal via peerDependencies: { "secrets.resolveInPack": "supported" } is the correct path; runtime check is defense-in-depth)
  • secret_not_foundref doesn't resolve in the host's credential store
  • secret_access_denied — caller lacks read permission on ref (RBAC denied)
  • secret_revoked — credential was revoked since last successful resolution (advisory: the host MAY surface this as secret_not_found to avoid leaking lifecycle metadata)
  • secret_expired — credential is past its expiry and rotation is required
  • secret_quota_exhausted — host-side rate limit on resolution calls (defense against bulk-leak attacks)

Capability advertisement shape:

{
  "secrets": {
    "supported": true,
    "scopes": ["tenant", "user"],
    "resolution": "host-managed",
    "resolveInPack": "supported"
  }
}

resolveInPack is additive — hosts that omit it advertise only the proxy-flow path (clients pass ai.credentialRef to ctx.callAI; pack-side resolution is unavailable). Hosts that advertise it MUST implement all hard rules above.


§host.credentials

Capability flag: credentials.supported: true _(advertised via top-level Capabilities.credentials; see capabilities.md §credentials)_ — RFC 0046, Draft.

Used by: packs that declare requiredCredentials[] (see node-pack-manifest.schema.json); the RFC 0047 host.oauth flow stores acquired tokens here; RFC 0045 connectors point their auth declarations at it.

A portable credential resolution + lifecycle contract — the first-class sibling to §host.secrets. Where secrets.resolveInPack hands raw plaintext into the pack process for direct external HTTP, host.credentials is the broader surface for _storing, sharing, rotating, and resolving_ a credential by an opaque reference, without ever putting plaintext on the wire. A pack references a credential by { ref, scope } (the credential-reference.schema.json wire shape — the reference, never the secret); the host resolves it at node-execution time and injects the material into the node sandbox only.

Resolution contract (normative). A host advertising credentials.supported: true MUST:

1. Resolve a { ref, scope } reference at node-execution time and inject the resolved material into the node sandbox only. The resolved value MUST NOT appear in inputs, persisted variables, channels, any run.* event payload, the debug bundle, or replay state (SECURITY invariant credential-payload-redaction). 2. Return a typed error envelope on resolution failure: credential_not_found (unknown ref), credential_forbidden (ref out of the caller's scope — fail-closed), credential_scope_unsupported (scope not in the advertised scopes). 3. When sharing: true, resolve the same stored credential for every workflow that references it within the scope, without copying material between references.

Rotation (when rotation: "two-key-overlap"). During a grace window the old and new credential both resolve as valid; after the window the old MUST fail with credential_not_found. Redaction (rule 1) MUST hold for both old and new material throughout. This reuses the contract verified for openwop-auth-api-key-rotation.

Relationship to §host.secrets. host.credentials supersedes the informal ai.credentialRef / secrets.resolveInPack annex with a first-class store-at-rest + sharing + rotation surface; the secrets advertisement stays valid and is now a special case. A host MAY advertise both.

Failure modes:

  • credential_not_foundref doesn't resolve (or old key past the rotation grace window)
  • credential_forbiddenref resolvable but out of the caller's { tenant, workspace, principal } scope (fail-closed; never silently substitute)
  • credential_scope_unsupported — requested scope not in capabilities.credentials.scopes
  • credential_unavailable — pack declares requiredCredentials[] but the host doesn't advertise credentials.supported (register-time refusal via peerDependencies: { "credentials": "supported" })

Capability advertisement shape:

{
  "credentials": {
    "supported": true,
    "scopes": ["user", "workspace", "tenant"],
    "encryptionAtRest": true,
    "rotation": "two-key-overlap",
    "sharing": true
  }
}

Additive — hosts that omit the block ignore it; packs declaring requiredCredentials[] refuse to register against them. Verified by credentials-capability-shape.test.ts (shape, always runs) and credential-payload-redaction.test.ts (adversarial redaction, capability-gated).


§host.oauth

Capability flag: oauth.supported: true _(advertised via top-level Capabilities.oauth; see capabilities.md §oauth)_ — RFC 0047, Draft.

Used by: connector packs whose nodes declare auth: { type: 'oauth2', provider, scopes[] } (see node-pack-manifest.schema.json NodeAuth). RFC 0045 connectors point their auth declaration here.

The host performs the OAuth 2.0 authorization-code + refresh dance on a user's behalf, so a connector node declares _which provider + scopes_ it needs — not _how_ the token is obtained. This answers "what third-party token does a node hold," distinct from RFC 0010's host-authentication profiles ("who is the caller"). It composes with — and depends on — §host.credentials: acquired tokens are stored as host.credentials entries and resolved into the node sandbox as a bearer token, never crossing the wire.

Token lifecycle (normative). A host advertising oauth.supported: true MUST:

1. Perform the advertised grant(s). For authorization_code, drive the redirect/callback exchange host-side; the authorization-code, redirect URI, and state parameter MUST NOT enter any run-visible surface. 2. Persist the acquired access + refresh tokens as a host.credentials (RFC 0046) entry (scope user or workspace); the node receives a resolved bearer token in-sandbox only — never in inputs, variables, events, debug bundles, or replay state (the credential-payload-redaction invariant covers it). 3. Refresh expired access tokens host-side using the stored refresh token, transparently to the node. On terminal refresh failure (revoked/expired refresh token), emit connector.auth_expired and fail the node with connector_auth_expired.

Connector-auth declaration. A node declares auth: { type: 'oauth2', provider, scopes[] }. The host matches provider against an advertised oauth.providers[].id and refuses to register the pack if the provider or a requested scope is not advertised (oauth_provider_unsupported / oauth_scope_unsupported).

Provider definition source (RFC 0095). What a provider id resolves to — its authorize/token/revoke endpoints, scope catalog, and reach — has a portable, registry-distributable representation: the connection pack (connection-packs.md, kind: "connection"). On a host advertising capabilities.connections.packsSupported: true, resolution MUST follow connection-packs.md §Manifest clause 6 (installed pack keyed by provider.id, installed-vs-built-in precedence per SemVer §11, connection_provider_unresolved / connection_provider_conflict diagnostics). Hosts without that flag keep their provider catalog implementation-defined, exactly as before RFC 0095.

Events (additive, redaction-safe):

  • connector.authorized{ provider, credentialRef, scopes } — token first acquired or re-authorized. Carries the credential reference, never the token.
  • connector.auth_expired{ provider, credentialRef, reason } — refresh failed terminally.

Failure modes:

  • oauth_provider_unsupported — node's auth.provider not in capabilities.oauth.providers[]
  • oauth_scope_unsupported — a requested scope not in the provider's scopesSupported
  • connector_auth_expired — stored token's refresh failed terminally

Capability advertisement shape:

{
  "oauth": {
    "supported": true,
    "grants": ["authorization_code", "refresh_token"],
    "providers": [
      { "id": "slack", "authUrl": "https://slack.com/oauth/v2/authorize", "tokenUrl": "https://slack.com/api/oauth.v2.access", "scopesSupported": ["chat:write", "channels:read"] }
    ]
  }
}

Additive — hosts that omit the block advertise no third-party token acquisition; connector packs declaring auth: { type: 'oauth2' } refuse to register against them. Depends on RFC 0046 for token storage + the redaction invariant. Verified by oauth-capability-shape.test.ts (shape, always runs) and oauth-connector-redaction.test.ts (token-material redaction, capability-gated, seam soft-skips).


§host.fs

Capability flag: fs.supported: true _(advertised via top-level Capabilities.fs; see capabilities.md)_

Used by: core.openwop.files (read / write / delete / stat / list nodes); transport sub-surfaces (FTP / SFTP / SSH) gate the corresponding core.openwop.files.transport.* nodes.

A sandboxed filesystem surface. Every path-bearing call is resolved relative to the host-configured sandboxRoot; path-traversal and symlink-escape MUST be rejected.

ctx.fs.read({ path: string }) → Promise<{ bytes: Uint8Array, contentType?: string }>
ctx.fs.write({ path: string, bytes: Uint8Array, contentType?: string }) → Promise<{ path: string, sizeBytes: number }>
ctx.fs.delete({ path: string }) → Promise<{ deleted: boolean }>
ctx.fs.stat({ path: string }) → Promise<{ sizeBytes: number, modifiedAt: string, contentType?: string }>
ctx.fs.list({ prefix?: string, cursor?: string, limit?: number }) → Promise<{ entries: Array<{ path: string, sizeBytes: number }>, nextCursor?: string }>

Required methods: read, write, delete, stat, list. The image, pdf, and transport.{ftp,sftp,ssh} sub-surfaces are optional and gate the corresponding pack delegates.

Hard rules:

RuleDetail
Path resolutionEvery path MUST be normalized and resolved relative to sandboxRoot. Absolute paths outside the root MUST return path_outside_sandbox.
Path traversalPaths containing .. segments that escape the root MUST return path_outside_sandbox.
Symlink escapeSymlinks that resolve outside the sandbox root MUST return path_outside_sandbox. The host MUST NOT follow such links partially.
Size enforcementWrites exceeding maxFileSizeBytes MUST return file_too_large. Reads of larger files MAY return file_too_large rather than streaming.
Permission errorsPermission denial MUST return fs_permission_denied, not silently fail or fall through.

Capability advertisement shape:

{
  "fs": {
    "supported": true,
    "sandboxRoot": "/var/openwop/fs",
    "maxFileSizeBytes": 104857600,
    "image": { "supported": true, "formats": ["jpeg","png","webp"] },
    "pdf":   { "supported": true },
    "transport": { "ftp": false, "sftp": true, "ssh": false }
  }
}

Failure modes: host_capability_missing · path_outside_sandbox · file_too_large · fs_permission_denied · file_not_found.

SECURITY invariant: fs-path-traversal (SECURITY/invariants.yaml) — verified by conformance/src/scenarios/fs-path-traversal.test.ts. Source: RFC 0014 §B–C.


§host.kvStorage

Capability flag: kvStorage.supported: true

Used by: core.openwop.storage kv-\* nodes (get / put / delete / cas / atomic-increment / ttl).

TTL-aware key-value store with atomic primitives. Per-tenant isolation is non-negotiable.

ctx.storage.kv.get({ key: string }) → Promise<{ value?: unknown, expiresAt?: string }>
ctx.storage.kv.put({ key: string, value: unknown, ttlSeconds?: number }) → Promise<{ ok: true }>
ctx.storage.kv.delete({ key: string }) → Promise<{ deleted: boolean }>
ctx.storage.kv.atomicIncrement({ key: string, delta?: number }) → Promise<{ value: number }>
ctx.storage.kv.compareAndSwap({ key: string, expectedValue: unknown, newValue: unknown }) → Promise<{ swapped: boolean }>
ctx.storage.kv.list({ prefix?: string, cursor?: string, limit?: number }) → Promise<{ entries: Array<{ key: string }>, nextCursor?: string }>

Required methods: get, put, delete, list. atomicIncrement and compareAndSwap are conditionally required when the corresponding capability flag is advertised.

Hard rules:

RuleDetail
Cross-tenant isolationA get for tenant A MUST NOT return values written by tenant B, even with identical keys. Same applies to list enumeration. Mirrors agent-memory-cti-1.
Size limitsKeys exceeding maxKeyBytes MUST be rejected; values exceeding maxValueBytes MUST be rejected.
TTL driftExpiry visibility MUST be honored with at most 1-second drift.
Atomic incrementWhen atomicIncrement: true is advertised, increments MUST be atomic across concurrent callers.
Compare-and-swapWhen compareAndSwap: true is advertised, CAS MUST be atomic (no read-modify-write races). Stale expectedValue returns {swapped: false} without mutation.

Capability advertisement shape:

{
  "kvStorage": {
    "supported": true,
    "maxKeyBytes": 256,
    "maxValueBytes": 1048576,
    "maxTtlSeconds": 2592000,
    "atomicIncrement": true,
    "compareAndSwap": true
  }
}

Failure modes: host_capability_missing · kv_key_too_large · kv_value_too_large · kv_ttl_exceeds_max · kv_quota_exhausted.

SECURITY invariant: kv-cross-tenant-isolation — verified by conformance/src/scenarios/kv-cross-tenant-isolation.test.ts + kv-atomic-increment.test.ts + kv-cas.test.ts + kv-ttl-expiry.test.ts. Source: RFC 0015 §B–C.


§host.tableStorage

Capability flag: tableStorage.supported: true

Used by: core.openwop.storage table-\* nodes (row CRUD + cursor pagination + schema enforcement).

Structured-record store. Sibling of host.kvStorage for workflows that need typed columns rather than opaque values. Schema is declared on first insert; subsequent rows MUST conform.

ctx.storage.table.createTable({ name: string, schema: Record<string, 'string'|'number'|'boolean'|'json'> }) → Promise<{ ok: true }>
ctx.storage.table.insert({ table: string, row: Record<string, unknown> }) → Promise<{ rowId: string }>
ctx.storage.table.get({ table: string, rowId: string }) → Promise<{ row?: Record<string, unknown> }>
ctx.storage.table.query({ table: string, filter?: Record<string, unknown>, cursor?: string, limit?: number }) → Promise<{ rows: Array<Record<string, unknown>>, nextCursor?: string }>
ctx.storage.table.update({ table: string, rowId: string, patch: Record<string, unknown> }) → Promise<{ ok: true }>
ctx.storage.table.delete({ table: string, rowId: string }) → Promise<{ deleted: boolean }>

Required methods: all six.

Hard rules:

RuleDetail
Cross-tenant isolationA query for tenant A MUST NOT return rows written by tenant B. Same applies to direct get by rowId. Mirrors kv-cross-tenant-isolation.
Schema enforcementInsert / update MUST reject rows whose column types diverge from the declared schema, returning table_schema_violation.
Cursor paginationquery MUST support cursor-based pagination; nextCursor MUST be opaque and stable across calls.
Row count limitInsert MUST be rejected when maxRowsPerTable is reached, returning table_row_limit_reached.

Capability advertisement shape:

{
  "tableStorage": {
    "supported": true,
    "maxRowsPerTable": 1000000,
    "maxColumnsPerRow": 64,
    "indexable": true,
    "fullTextSearch": false
  }
}

Failure modes: host_capability_missing · table_schema_violation · table_row_limit_reached · table_not_found.

SECURITY invariant: table-cross-tenant-isolation — verified by conformance/src/scenarios/table-cross-tenant-isolation.test.ts + table-cursor-pagination.test.ts + table-schema-enforcement.test.ts. Source: RFC 0016 §B–C.


§host.queueBus

Capability flag: queueBus.supported: true

Used by: core.openwop.messaging consume / publish / ack / nack / DLQ / stream-subscribe nodes. Sibling of (existing) host.messaging — that surface is outbound-egress-only; host.queueBus covers full message-queue semantics including delivery acknowledgement and inbound triggers.

ctx.queueBus.publish({ topic: string, payload: unknown, headers?: Record<string,string> }) → Promise<{ messageId: string }>
ctx.queueBus.consume({ topic: string, consumerGroup?: string, maxMessages?: number }) → Promise<{ messages: Array<{ messageId: string, payload: unknown, deliveryToken: string }> }>
ctx.queueBus.ack({ deliveryToken: string }) → Promise<{ ok: true }>
ctx.queueBus.nack({ deliveryToken: string, requeue?: boolean }) → Promise<{ ok: true }>
ctx.queueBus.deadLetter({ deliveryToken: string, reason: string }) → Promise<{ ok: true }>
ctx.queueBus.streamSubscribe({ topic: string, fromBeginning?: boolean }) → AsyncIterable<{ messageId: string, payload: unknown }>

Required methods: publish, consume, ack, nack. deadLetter is required when deadLetterSupported: true is advertised. streamSubscribe is required when stream.supported: true is advertised; fromBeginning: true is gated on stream.fromBeginning: true.

Hard rules:

RuleDetail
Cross-tenant isolationA consumer for tenant A MUST NOT receive messages published by tenant B, even on the same logical topic.
Ack semanticsack MUST remove the message from the queue; nack MUST return it for redelivery; deadLetter MUST route it to the configured DLQ.
Trigger deliveryWhen a workflow registers core.messaging.consume as a trigger, the host MUST deliver one workflow run per inbound message — no batching, no skipping.
Backend transparencyHosts MAY back the surface with any advertised backend (rabbitmq, kafka, sqs, etc.); wire shape MUST be backend-invariant.

Capability advertisement shape:

{
  "queueBus": {
    "supported": true,
    "backends": ["rabbitmq", "sqs", "in-memory"],
    "deadLetterSupported": true,
    "stream": { "supported": true, "fromBeginning": true }
  }
}

Failure modes: host_capability_missing · queue_topic_not_found · queue_delivery_token_expired · queue_backend_unavailable.

SECURITY invariant: queue-cross-tenant-isolation — verified by conformance/src/scenarios/queue-cross-tenant-isolation.test.ts + queue-publish-consume-roundtrip.test.ts + queue-ack-nack-dlq.test.ts + stream-subscribe-from-beginning.test.ts. Source: RFC 0017 §B–C.


§host.sql

Capability flag: sql.supported: true

Used by: core.openwop.db sql-\* nodes. SQL injection prevention is enforced at the host — the pack MUST NOT concatenate user input into SQL.

ctx.db.sql.query({ datasourceId: string, sql: string, params: ReadonlyArray<unknown> }) → Promise<{ rows: Array<Record<string, unknown>>, rowCount: number }>
ctx.db.sql.execute({ datasourceId: string, sql: string, params: ReadonlyArray<unknown> }) → Promise<{ rowsAffected: number }>
ctx.db.sql.transaction({ datasourceId: string, operations: Array<{ sql: string, params: ReadonlyArray<unknown> }> }) → Promise<{ committed: boolean }>

Required methods: query, execute. transaction is required when transactions: true is advertised.

Hard rules:

RuleDetail
Parametric-onlysql MUST be treated as a parametric template; bound values MUST flow through params[], never via string interpolation. Hosts SHOULD verify parameter binding before execution.
Cross-datasource isolationDatasources are scoped per tenant; cross-tenant access MUST return datasource_access_denied.
Transaction atomicityWhen transactions: true is advertised, partial failure inside transaction MUST roll back the entire batch.
Driver transparencyHosts MAY back the surface with any advertised driver (postgres, mysql, sqlite, etc.); wire shape MUST be driver-invariant.

Capability advertisement shape:

{
  "sql": {
    "supported": true,
    "datasources": [{ "id": "primary", "driver": "postgres" }],
    "transactions": true,
    "drivers": ["postgres", "sqlite"]
  }
}

Failure modes: host_capability_missing · sql_non_parametric · datasource_not_found · datasource_access_denied · sql_syntax_error · sql_transaction_aborted.

SECURITY invariant: sql-parametric-only — verified by conformance/src/scenarios/sql-injection-rejection.test.ts + sql-transaction-atomicity.test.ts. Source: RFC 0018 §B–C.


§host.nosql

Capability flag: nosql.supported: true

Used by: core.openwop.db nosql-\* nodes (document-store CRUD).

Document-store sibling of host.sql. Driver-invariant document API; backends include MongoDB, DynamoDB, CosmosDB, Firestore.

ctx.db.nosql.insert({ datasourceId: string, collection: string, doc: Record<string, unknown> }) → Promise<{ id: string }>
ctx.db.nosql.get({ datasourceId: string, collection: string, id: string }) → Promise<{ doc?: Record<string, unknown> }>
ctx.db.nosql.query({ datasourceId: string, collection: string, filter: Record<string, unknown>, cursor?: string, limit?: number }) → Promise<{ docs: Array<Record<string, unknown>>, nextCursor?: string }>
ctx.db.nosql.update({ datasourceId: string, collection: string, id: string, patch: Record<string, unknown> }) → Promise<{ ok: true }>
ctx.db.nosql.delete({ datasourceId: string, collection: string, id: string }) → Promise<{ deleted: boolean }>

Required methods: insert, get, query, update, delete.

Hard rules:

RuleDetail
Cross-tenant isolationDatasources are scoped per tenant; cross-tenant access MUST return datasource_access_denied.
Filter sanitizationfilter operators MUST NOT permit injection (e.g., $where JavaScript evaluation in MongoDB MUST be rejected unless an explicit allowlist is configured).
Driver transparencyWire shape MUST be driver-invariant across advertised backends.

Capability advertisement shape:

{
  "nosql": {
    "supported": true,
    "datasources": [{ "id": "primary", "driver": "mongodb" }],
    "drivers": ["mongodb", "dynamodb", "cosmosdb", "firestore"]
  }
}

Failure modes: host_capability_missing · datasource_not_found · datasource_access_denied · nosql_filter_rejected.

Source: RFC 0018 §A–B.


§host.vectorStore

Capability flag: vectorStore.supported: true

Used by: core.openwop.rag vector-\* nodes (upsert + KNN query + delete). Required by RAG packs that need similarity search.

ctx.db.vector.upsert({ collection: string, vectors: Array<{ id: string, embedding: ReadonlyArray<number>, metadata?: Record<string, unknown> }> }) → Promise<{ upserted: number }>
ctx.db.vector.query({ collection: string, embedding: ReadonlyArray<number>, k: number, filter?: Record<string, unknown> }) → Promise<{ matches: Array<{ id: string, score: number, metadata?: Record<string, unknown> }> }>
ctx.db.vector.delete({ collection: string, ids: ReadonlyArray<string> }) → Promise<{ deleted: number }>

Required methods: upsert, query, delete.

Hard rules:

RuleDetail
Cross-tenant isolationA query for tenant A MUST NOT return vectors written by tenant B, even within the same collection name.
KNN roundtripAn upsert followed by query with the same embedding MUST return the inserted ids in the top-k matches when k ≥
Backend transparencyWire shape MUST be backend-invariant across advertised backends (pinecone, qdrant, pgvector, in-memory, etc.).

Capability advertisement shape:

{
  "vectorStore": {
    "supported": true,
    "collections": [{ "name": "documents", "dimensions": 1536 }],
    "backends": ["pgvector", "in-memory"]
  }
}

Failure modes: host_capability_missing · vector_collection_not_found · vector_dimension_mismatch.

Conformance: conformance/src/scenarios/vector-knn-roundtrip.test.ts. Source: RFC 0018 §A–B.


§host.searchIndex

Capability flag: searchIndex.supported: true

Used by: core.openwop.rag search-\* nodes (full-text / BM25 ranking). Sibling of host.vectorStore for lexical-rather-than-semantic retrieval.

ctx.db.search.index({ index: string, docs: Array<{ id: string, fields: Record<string, string|number|boolean> }> }) → Promise<{ indexed: number }>
ctx.db.search.query({ index: string, q: string, k?: number, filter?: Record<string, unknown> }) → Promise<{ hits: Array<{ id: string, score: number, fields?: Record<string, unknown> }> }>
ctx.db.search.delete({ index: string, ids: ReadonlyArray<string> }) → Promise<{ deleted: number }>

Required methods: index, query, delete.

Hard rules:

RuleDetail
Cross-tenant isolationA query for tenant A MUST NOT return hits indexed by tenant B.
BM25 roundtripAn index followed by query with a substring of an indexed field MUST return the indexed id with score > 0.
Backend transparencyWire shape MUST be backend-invariant (elasticsearch, opensearch, meilisearch, typesense, algolia, in-memory linear scan).

Capability advertisement shape:

{
  "searchIndex": {
    "supported": true,
    "indexes": [{ "name": "docs" }],
    "backends": ["meilisearch", "in-memory"]
  }
}

Failure modes: host_capability_missing · search_index_not_found · search_query_syntax_error.

Conformance: conformance/src/scenarios/search-bm25-roundtrip.test.ts. Source: RFC 0018 §A–B.


§host.blobStorage

Capability flag: blobStorage.supported: true

Used by: core.openwop.storage blob-\* nodes (binary artifact store with presigned URLs). S3 / GCS / Azure Blob equivalent.

ctx.storage.blob.put({ bucket: string, key: string, bytes: Uint8Array, contentType?: string }) → Promise<{ url: string, sizeBytes: number }>
ctx.storage.blob.get({ bucket: string, key: string }) → Promise<{ bytes: Uint8Array, contentType?: string }>
ctx.storage.blob.delete({ bucket: string, key: string }) → Promise<{ deleted: boolean }>
ctx.storage.blob.presign({ bucket: string, key: string, expiresInSeconds: number, method: 'GET'|'PUT' }) → Promise<{ url: string, expiresAt: string }>
ctx.storage.blob.list({ bucket: string, prefix?: string, cursor?: string }) → Promise<{ entries: Array<{ key: string, sizeBytes: number }>, nextCursor?: string }>

Required methods: put, get, delete, list. presign is required when presignSupported: true is advertised.

Hard rules:

RuleDetail
Cross-tenant isolationA get for tenant A MUST NOT return blobs written by tenant B, even with identical bucket/key.
Presigned URL expiryPresigned URLs MUST expire at the advertised TTL; presigned requests after expiry MUST fail at the storage layer, not after auth-skip.
Object size limitWrites exceeding maxObjectBytes MUST return blob_object_too_large.

Capability advertisement shape:

{
  "blobStorage": {
    "supported": true,
    "buckets": [{ "name": "artifacts", "region": "us-central1" }],
    "presignSupported": true,
    "maxObjectBytes": 5368709120
  }
}

Failure modes: host_capability_missing · blob_bucket_not_found · blob_object_not_found · blob_object_too_large · blob_presign_expired.

SECURITY invariant: blob-cross-tenant-isolation — verified by conformance/src/scenarios/blob-cross-tenant-isolation.test.ts + blob-roundtrip.test.ts + blob-presign-expiry.test.ts. Source: RFC 0019 §B–C.


§host.cache

Capability flag: cache.supported: true

Used by: core.openwop.storage cache-\* nodes (TTL cache for HTTP / AI response memoization). Lets idempotency-key replay deduplicate identical AI calls across runs without engaging the heavier Layer-2 invocation log.

ctx.storage.cache.get({ key: string }) → Promise<{ value?: unknown, expiresAt?: string }>
ctx.storage.cache.put({ key: string, value: unknown, ttlSeconds: number }) → Promise<{ ok: true }>
ctx.storage.cache.delete({ key: string }) → Promise<{ deleted: boolean }>

Required methods: get, put, delete.

Hard rules:

RuleDetail
Cross-tenant isolationA get for tenant A MUST NOT return values written by tenant B, even with identical keys.
TTL driftExpiry visibility MUST be honored with at most 1-second drift on read.
Value size limitWrites exceeding maxValueBytes MUST return cache_value_too_large.

Capability advertisement shape:

{
  "cache": {
    "supported": true,
    "maxValueBytes": 1048576,
    "maxTtlSeconds": 86400
  }
}

Failure modes: host_capability_missing · cache_value_too_large · cache_ttl_exceeds_max.

SECURITY invariant: cache-cross-tenant-isolation — verified by conformance/src/scenarios/cache-cross-tenant-isolation.test.ts + cache-ttl-expiry.test.ts. Source: RFC 0019 §B–C.


Sandbox execution contract (RFC 0035)

Per RFC 0035 (Active 2026-05-21).

Sandbox is a meta-capability: it governs how OTHER host capabilities (host.fs, host.kvStorage, host.sql, et al.) are exposed to pack-loaded code. It lives at the top-level capabilities.sandbox block (NOT under host.* — sandbox isn't itself a pack-consumable surface; it's the runtime envelope around them).

Capability advertisement (normative)

{
  "capabilities": {
    "sandbox": {
      "supported": true,
      "isolationModel": "wasm",            // or "process" | "container" | "vm" | "x-host-<host>-<key>"
      "allowedHostCalls": ["host.fs", "host.kvStorage"],
      "memoryLimitBytes": 67108864,         // 64 MiB
      "wallClockLimitMs": 10000             // 10 s
    }
  }
}

A host that advertises capabilities.sandbox.supported: true MUST enforce all 8 failure-mode invariants below. A host that does NOT advertise (omits the block OR sets supported: false) MUST refuse to load any pack whose manifest declares peerDependencies.host.sandbox: required with refusal code capability_not_provided per capabilities.md §"Runtime capabilities."

Failure-mode invariants (normative)

Invariant idMUST contract
node-pack-sandbox-no-host-fs-escapeSandbox code MUST NOT read or write files outside the host-advertised sandbox root. Attempting to escape MUST fail closed with sandbox_escape_attempt.
node-pack-sandbox-no-host-env-leakHost environment variables MUST NOT be visible to sandbox code unless the host has explicitly forwarded them via an allowedHostCalls entry that exposes env-resolution.
node-pack-sandbox-no-network-escapeSandbox code MUST NOT initiate network requests unless host.fetch (or equivalent) is in allowedHostCalls.
node-pack-sandbox-no-host-process-escapeSandbox code MUST NOT spawn host processes, fork, or call exec-family syscalls.
node-pack-sandbox-memory-capExceeding memoryLimitBytes MUST fail the node with error.code: "sandbox_memory_exceeded".
node-pack-sandbox-timeout-capExceeding wallClockLimitMs MUST fail the node with error.code: "sandbox_timeout".
node-pack-sandbox-capability-gate-respectedSandbox code MUST NOT bypass the host's capability-advertisement check; calls to undeclared host capabilities MUST fail closed with sandbox_capability_denied.
node-pack-sandbox-no-cross-pack-mutationSandbox code from pack A MUST NOT mutate state visible to pack B inside the same host process.

SECURITY/invariants.yaml carries the 8 matching rows. The graduation from reference-impl to protocol tier is gated on a reference host implementing the sandbox AND passing the 8 conformance scenarios named in RFC 0035 §D.

Error codes (additive to rest-endpoints.md §"Common error codes")

  • sandbox_memory_exceeded — Sandbox invocation exceeded memoryLimitBytes. details.requestedBytes MAY be present.
  • sandbox_timeout — Sandbox invocation exceeded wallClockLimitMs.
  • sandbox_capability_denied — Sandbox code called a host capability not in allowedHostCalls. details.requestedCapability MUST be set.
  • sandbox_escape_attempt — Sandbox detected an explicit escape attempt (a syscall from a forbidden list). details.escapeKind SHOULD be set.

§host.scheduling

Capability flag: scheduling.supported: true _(advertised via top-level Capabilities.scheduling; see capabilities.md §scheduling)_ — RFC 0052, Draft.

Used by: the schedule trigger in core.openwop.triggers (cron / delayed / calendar). Promotes the scheduling intent behind RFC 0017 (host.queueBus) into a portable, conformance-tested execution contract.

Gives the schedule trigger time-based run-initiation semantics: a cron expression, one-shot delay, or calendar reference produces a durable scheduled run. This is orthogonal to the in-DAG core.control.delay primitive (which reads config.delayMs and delays a node _within_ a run); host.scheduling _initiates_ runs at a time. It composes with host.queueBus (RFC 0017) where a host backs scheduling with a queue, but a host MAY advertise scheduling without queueBus (e.g. a cron daemon).

Contract (normative). A host advertising scheduling.supported: true MUST, for a schedule trigger configured with a cron expr / delayMs / calendar ref:

1. Produce a durable scheduled run that survives host restart and fires at the scheduled time. 2. Fire exactly once per tick — a cron tick MUST NOT spawn duplicate concurrent runs (idempotent firing; composes with idempotency.md). 3. Reject a schedule beyond the advertised maxFutureHorizon with schedule_horizon_exceeded. 4. Apply a documented missed-tick policy — when the host was down across a tick it MUST either fire-once-on-recovery or skip-to-next (advertised so consumers can reason about it); it MUST NOT silently fire N backlogged runs.

Capability advertisement shape:

{
  "scheduling": {
    "supported": true,
    "cron": true,
    "delayed": true,
    "calendar": false,
    "maxFutureHorizon": "P90D"
  }
}

Error codes (additive to rest-endpoints.md §"Common error codes"):

  • schedule_horizon_exceeded — a schedule trigger requested a fire time beyond capabilities.scheduling.maxFutureHorizon. details.maxFutureHorizon SHOULD echo the advertised cap.

Additive — hosts that omit the block advertise no scheduling; schedule-trigger workflows refuse to register against them (peerDependency scheduling: 'supported'). Verified by scheduling-capability-shape.test.ts (shape, always runs) + scheduling-cron-fires-once.test.ts (once-per-tick + missed-tick, capability-gated).


§host.heartbeat

Capability flag: heartbeat.supported: true _(advertised via top-level Capabilities.heartbeat; see capabilities.md §heartbeat)_ — RFC 0060, Draft.

Used by: system-managed, predicate-gated polling — the controlled, request-shaped exception to openwop's poll-free design (positioning.md). Composes on host.scheduling (RFC 0052) for the once-per-tick interval substrate.

A heartbeat binds a predicate (a node/workflow designated as the heartbeat handler) to an interval. It wakes on a short interval to inspect external state (an inbox, a queue, a sensor) and acts _only_ when an idempotent predicate transitions — enqueuing a run or notifying a human — rather than re-running the agent blindly every tick. host.scheduling answers "when"; host.heartbeat answers "evaluate cheaply, and act only on change." A host MAY advertise scheduling without heartbeat; a host advertising heartbeat SHOULD also advertise scheduling (the tick source) and, if it does not, MUST document its own interval substrate.

Contract (normative). On each tick, a host advertising heartbeat.supported: true MUST:

1. Fire exactly once per tick — no overlapping evaluations of the same heartbeat; if a prior tick's evaluation is still running, the host MUST skip (not queue) the new tick (composes with idempotency.md + RFC 0052's once-per-tick rule). 2. Bound the evaluation to maxRuntimeMs (hard-ceilinged by capabilities.limits.maxRunDurationMs, RFC 0058); an over-budget predicate MUST be terminated and reported heartbeat.evaluated { status: "timeout" }, never left running. 3. Be idempotent — the predicate receives the prior tick's emitted state (an opaque host-persisted token) and MUST be a pure function of observed external state + prior state. The host MUST NOT perform a side effect directly; the predicate's _output_ drives action. 4. Emit heartbeat.evaluated every tick — { heartbeatId, status: "ok" | "timeout" | "error", changed: boolean }. 5. On a state transition only, emit heartbeat.stateChanged { heartbeatId, from, to } and — if the predicate requests it — enqueue a run via the existing POST /v1/runs path. An unchanged tick MUST NOT enqueue a run or emit heartbeat.stateChanged.

Gating action on a _transition_ (computed against persisted prior state) — not on the tick itself — is what prevents notification spam.

First tick (no persisted prior state). The first evaluation of a heartbeatId has no prior state to compare against. The host MUST treat this as a transition (changed: true) — establishing the baseline by emitting heartbeat.stateChanged { from: {}, to: <observed> } and enqueuing if requested — rather than silently seeding the baseline. This keeps the first non-empty observation actionable (an inbox first seen at unread: 3 MUST notify) and makes the transition rule total over the lifetime of a heartbeatId. A host that loses persisted prior state (e.g. an in-memory host across a restart) therefore re-fires the next tick as a first tick; durable hosts SHOULD persist prior state to avoid a post-restart re-notification.

Capability advertisement shape:

{
  "heartbeat": {
    "supported": true,
    "minIntervalSec": 900,
    "maxRuntimeMs": 5000
  }
}

Events (advertised in api/asyncapi.yaml; heartbeat-scoped, NOT run-event-log entries):

  • heartbeat.evaluated{ heartbeatId, status, changed }, emitted every tick (observability).
  • heartbeat.stateChanged{ heartbeatId, from, to }, emitted only on a predicate-state transition.

Additive — hosts that omit the block advertise no heartbeat. Verified by heartbeat-capability-shape.test.ts (shape, always runs) + heartbeat-fires-once-per-tick.test.ts / heartbeat-idempotent-no-spam.test.ts / heartbeat-runtime-bound.test.ts (capability-gated).


§host.http

Capability flag: capabilities.httpClient.supported: true — the host's outbound-HTTP surface. RFC 0076 §B (Active) adds the OPTIONAL httpClient.safeFetch sub-capability.

Used by: node egress (core.http.request-class nodes) and — when safeFetch is advertised — pack runtime code via ctx.http.safeFetch.

SSRF guard (normative — the http-client-ssrf-guard invariant). A host advertising httpClient MUST set ssrfGuard: true and a positive maxResponseBodyBytes. Before connecting, the host MUST resolve the target, reject (loopback / RFC 1918 private / link-local / cloud-metadata, e.g. 169.254.169.254, metadata.google.internal) and pin the resolved IP for the connection so a re-resolution cannot redirect to a blocked address (DNS-rebinding defeat). This is the existing http-client-ssrf-guard protocol-tier invariant (SECURITY/invariants.yaml) — no new invariant; safeFetch is one more surface under it.

safeFetch (RFC 0076 §B — pack-facing, OPTIONAL)

A host MAY advertise capabilities.httpClient.safeFetch.supported: true and expose ctx.http.safeFetch(url, init?) to pack runtime code — the pack-facing exposure of the SSRF-guarded client, so packs stop reaching for node:dns / raw sockets to do their own SSRF defense:

ctx.http.safeFetch(
  url: string,
  init?: RequestInit,        // method/headers/body subset, host-clamped
) → Promise<Response>        // standard fetch Response, or throws ssrf_blocked / fetch_failed

Behavior (normative, when safeFetch.supported):

  • The host MUST apply the §host.http SSRF guard above (resolve→pin→connect; throw ssrf_blocked on a blocked address) and MUST enforce maxResponseBodyBytes + (when set) requestTimeoutMs.
  • The host MUST refuse connection-upgrade attempts (Connection: upgrade / a 101 protocol switch) — these escape HTTP into a raw bidirectional socket and defeat the resolve→pin→connect boundary. Body-size / timeout / Authorization-forwarding policy are host tuning (the body cap + timeout reuse the httpClient fields above; an Authorization header a pack did not construct from a host-issued credential SHOULD NOT be forwarded — host policy; see RFC 0076 §B Q5).
  • When capabilities.toolHooks.prePostEvents: true is also advertised, the host MUST emit the agent.toolCalled / agent.toolReturned pair (transport: 'http') for every safeFetch invocation — centralizing egress in the host must increase auditability, not become a quiet bypass. Sampling belongs at the storage/projection tier; the wire-level emission stays unconditional (RFC 0064 posture).
  • A pack using safeFetch does not declare net.dns in runtime.requires for the fetch path (the host owns resolution); a pack that wants to run on hosts lacking the capability MAY feature-detect (ctx.http?.safeFetch) and fall back to its own net.outbound + net.dns path (declaring both). See RFC 0076 §A.

safeFetch composes with a deployment-level egress allowlist (e.g. Cloud NAT) as defense-in-depth: the host's resolve→pin→connect guard applies _before_ the proxy sees the request.

Capability advertisement shape:

{
  "httpClient": {
    "supported": true,
    "ssrfGuard": true,
    "maxResponseBodyBytes": 10485760,
    "requestTimeoutMs": 30000,
    "safeFetch": { "supported": true }
  }
}

Additive — hosts that omit safeFetch expose no ctx.http.safeFetch; packs feature-detect. Verified by http-client-ssrf.test.ts (advertisement contract, capability-gated) + safefetch-behavior.test.ts (SSRF block / rebinding / Connection: upgrade refusal / audit-when-both, seam-gated; soft-skips on 404 until a safeFetch host wires the seam) + safefetch-live-audit.test.ts (the audit-when-both MUST against the durable run event log rather than the seam's inline echo — a host advertising both safeFetch + toolHooks.prePostEvents that ships a production ctx.http.safeFetch with no audit hooks passes the inline seam but FAILS this scenario under OPENWOP_REQUIRE_BEHAVIOR=true; drives the POST /v1/host/sample/http/safe-fetch-run open seam, host-pending on 404). It asserts the pair on a guaranteed-blocked metadata URL as an egress-independent floor — the audit MUST is "for every safeFetch invocation," and a refused egress is itself audit-worthy — so the bar bites even on a host with no public egress, plus a best-effort public fetch for success-path coverage. This live-run scenario is the RFC 0076 §B → Accepted bar.

Credential provenance + egress policy (RFC 0079 §A–§F — Active)

Why this exists. safeFetch (above) guards the _URL_ (resolve→pin→connect, metadata block) but explicitly parked the credential question: when a tool attaches a host-issued credential (an RFC 0046 stored reference or an RFC 0047 OAuth token) to an egress, nothing checks that _this credential_ is meant for _this destination_. A prompt-injected or misconfigured tool can attach a credential minted for service A to a request aimed at attacker-controlled service B — a confused-deputy / credential-exfiltration class the SSRF guard does not catch. RFC 0079 closes it.

Capability flag: capabilities.httpClient.egressPolicy.supported: true — requires httpClient.safeFetch (the egress mechanism). Absent ⇒ the host does not perform provenance binding (the SSRF guard above still applies); the conformance behavioral scenarios skip cleanly.

§A — Credential provenance descriptor. When the host binds a credential for an egress at the tool boundary, it attaches a CredentialProvenance — metadata _about_ the credential (credentialId, issuer, REQUIRED audiences, optional scopes/expiresAt/redactionPolicy/auditCorrelationId), never the secret value (SR-1; reuses the RFC 0046 credential-payload-redaction posture).

§B — egress.decided event. A host advertising egressPolicy MUST emit the content-free egress.decided (run-event-payloads.schema.json egressDecided) when it evaluates a credentialed egress: { decision ∈ {allowed, denied, downgraded, approval-required}, destination, credentialId?, reason?, auditCorrelationId? } — identifiers + decision only, no credential value, no request/response body. destination is the host/authority ONLY (no path/query — a path/query can carry secrets; the host MUST strip them, keeping any path on a vendor x-host-* variant). reason is a CLOSED enum (ok/out-of-audience/expired/ssrf-blocked/provenance-unevaluable/scope-denied/policy-denied) — a free-form reason would let a host spill the blocked URL/host/header into it, defeating the content-free guarantee. On replay it is re-read from the log, never regenerated (a recorded fact).

§C — Audience-binding MUST (normative — the parked-question answer). When a host attaches a host-issued credential to an egress (via ctx.http.safeFetch or a tool):

1. The host MUST NOT attach the credential if the egress destination is not in the credential's provenance audiences (the confused-deputy guard) — such an egress is denied (reason: "out-of-audience") or, where host policy permits, downgraded (proceeds anonymously without the credential). 2. The host MUST fail closed: if provenance cannot be evaluated (no descriptor, unparseable audiences), the egress is denied (reason: "provenance-unevaluable") — never allowed-by-default. 3. An expired credential (expiresAt past) MUST NOT be attached (denied / reason: "expired"). 4. This composes the §host.http SSRF guard (URL-level); the audience binding is the credential-level check the SSRF guard does not perform — both MUST pass for allowed.

This is the load-bearing behavioral guarantee, tracked as the egress-credential-audience-bound SECURITY invariant (reference-impl tier until a host wires egressPolicy over safeFetch — graduates to protocol-tier at Active → Accepted per the RFC 0035 precedent). The content-free guarantee on egress.decided + the provenance descriptor (no secret value on the wire) is the protocol-tier egress-decision-no-secret-leak invariant, verified always-on by egress-provenance-shape.test.ts. audiences matching is exact host or an explicit *.domain suffix (no arbitrary regex — injection risk).

Additive — a host that omits egressPolicy keeps the SSRF guard unchanged. Verified by egress-provenance-shape.test.ts (always-on shape) + egress-audience-binding.test.ts + egress-decision-content-free.test.ts (gated on egressPolicy.supported, soft-skip until a host wires the seam).


§host.toolHooks

Capability flag: toolHooks.supported: true _(advertised via top-level Capabilities.toolHooks; see capabilities.md §toolHooks)_ — RFC 0064, Active.

Used by: per-tool authorization + rate limiting + a content-free tool-call audit trail, layered on the existing agent.toolCalled / agent.toolReturned events (RFC 0002). Generalizes the MCP-specific bridges across transports (mcp / http / native) — see mcp-integration.md. Reuses RFC 0049's forbidden error + authorization-fail-closed invariant and the existing rate_limited error; no new event type, error code, or SECURITY invariant.

Content-free audit (normative, when prePostEvents: true). For every external tool call, the host MUST populate the additive fields on the existing callId-paired events: agent.toolCalled gains argsHash (SHA-256 over RFC 8785 JCS-canonicalized args with resolved secrets already redacted per SR-1 — raw key material MUST NOT enter the hash input), principal (the RFC 0048 id; core.system for non-agent host egress — agentId stays REQUIRED, so non-agent calls use the reserved synthetic agent id), and transport; agent.toolReturned gains status (ok/error/forbidden/rate_limited) and durationMs (recorded, re-emitted verbatim on replay/:fork, never recomputed — replay.md).

Per-tool authorization (normative, when perToolAuthorization: true). A tool declares required scopes (actions[].requiredScopes[] in its connector/mount manifest, per RFC 0045). Before invoking, the host MUST check the run principal's RFC 0049 scopes and fail closed: if the principal lacks a scope or authorization cannot be evaluated, the host MUST NOT invoke the tool, MUST emit agent.toolReturned { status: 'forbidden' }, and MUST surface the existing forbidden (403) error with details.scope: 'tool' + details.toolName + details.requiredScopes. Absence of a decision is denial — this is the per-tool application of RFC 0049's authorization-fail-closed invariant.

Forbidden-at-load (clarification). A host MAY evaluate a manifest agent's toolAllowlist (RFC 0072 §D) _proactively at loop start_ — before the first model call — and emit agent.toolReturned { status: 'forbidden' } for an entry that resolves to no approved pack (unknown typeId, or a pack the workspace has not approved per RFC 0074). Because the model has not requested the tool, no agent.toolCalled precedes this row: the host MUST synthesize the callId (a stable derivation such as forbidden:<sha256(ref)> is RECOMMENDED so the row is replay-stable) and MAY omit causationId — RFC 0002 §B requires causationId only to anchor a toolReturned to its paired toolCalled, and here there is no parent event. A host MUST NOT synthesize a placeholder agent.toolCalled _solely_ to anchor the chain (it would assert a model request that never happened). Consumers reconstructing the causation graph MUST tolerate a forbidden/rate_limited agent.toolReturned whose callId has no matching agent.toolCalled.

Per-tool rate limiting (normative, when perToolRateLimit: true). The host MUST apply a token bucket keyed on (principal, toolName); on exhaustion it MUST NOT invoke the tool, emits agent.toolReturned { status: 'rate_limited' }, and surfaces the existing rate_limited (429, Retry-After) error with details.scope: 'tool' — distinct from the HTTP-inbound limiter (unchanged), same envelope.

Capability advertisement shape:

{
  "toolHooks": {
    "supported": true,
    "prePostEvents": true,
    "perToolAuthorization": true,
    "perToolRateLimit": false
  }
}

Additive — hosts that omit the block behave exactly as today (the agent.toolCalled/agent.toolReturned required arrays + callId pairing are unchanged; version < toolHooks consumers ignore the new fields). Verified by tool-hooks-shape.test.ts (shape, always runs) + tool-hooks-content-free.test.ts / tool-hooks-authorization-fail-closed.test.ts / tool-hooks-rate-limit.test.ts / tool-hooks-secret-redaction.test.ts (capability-gated).

Host implementation note — provider tool-name sanitization (non-normative). openwop tool ids carry : and . (<scope>:<tool-id>, e.g. openwop:core.openwop.http.fetch). The major model providers' function-calling APIs (Anthropic, OpenAI, Gemini) restrict tool names to [A-Za-z0-9_-] and reject those delimiters, so a host running a function-calling loop MUST map the openwop id to a provider-legal name on the request (a common choice is :/._, e.g. openwop_core_openwop_http_fetch) and reverse the mapping when matching the model's tool_use / tool_calls name back to the openwop tool. This mapping is purely a host↔provider adapter concern — it never appears on the openwop wire (the agent.toolCalled.toolId / toolName fields carry the canonical openwop id), so it is informative, not normative. It is called out here only to save the next implementer the rediscovery: a non-reversible or lossy sanitization (e.g. collapsing two distinct ids to the same provider name) silently breaks dispatch.


§host.deadLetter

Capability flag: deadLetter.supported: true _(advertised via top-level Capabilities.deadLetter; see capabilities.md §deadLetter)_ — RFC 0053, Draft.

Used by: the engine's terminal-failure path. Gives a run/node that exhausts its retry policy a durable, inspectable sink instead of being logged and lost — so a poisoned run can be examined and replayed.

This is the run-level dead-letter surface, distinct from queueBus.deadLetterSupported (RFC 0017), which dead-letters transport _messages_ on a queue backend, not _runs_ in the engine. It composes with the retry policy (RFC 0009) and fork/replay (RFC 0011).

Contract (normative). A host advertising deadLetter.supported: true MUST:

1. On retry exhaustion (per the RFC 0009 retry policy), route the run/node to the dead-letter sink and emit run.dead_lettered { runId, nodeId?, reason, attempts }. The run's terminal RunSnapshot.status reflects failure; the run is not purged before retentionDays. 2. Keep the dead-lettered run fork-eligible per RFC 0011 for the retention window — it can be forked/replayed (e.g. after the underlying cause is fixed). 3. Purge the run after retentionDays; a fork attempt on a purged run returns the existing RFC 0011 not-found error.

run.dead_lettered carries no credential or payload material beyond a redaction-safe reason.

Capability advertisement shape:

{
  "deadLetter": {
    "supported": true,
    "retentionDays": 30
  }
}

Additive — hosts that omit the block continue to fail runs with no sink, unaffected. Verified by deadletter-capability-shape.test.ts (shape, always runs) + deadletter-retry-exhaustion.test.ts (retry-exhaustion → run.dead_lettered + fork-eligibility, capability-gated).


§host.knowledge

Capability flag: host.knowledge: supported

Used by: market-intelligence packs, RAG-grounded copy packs, brief-enrichment packs.

Knowledge-base retrieval. Routes queries through the host's RAG pipeline (embedding → vector search → optional re-rank). The host owns the corpus, the embedding model, and the access-control boundary; the pack supplies the query.

ctx.knowledge.retrieve({
  query: string,
  workspaceId?: string,       // omit to use the run's workspace
  collectionIds?: string[],   // scope to specific knowledge collections
  category?: string,          // optional category facet
  candidateLimit?: number,    // pre-rank candidate pool size; host caps the upper bound
  resultLimit?: number,       // post-rank returned chunks; host caps the upper bound
  scoreThreshold?: number,    // minimum relevanceScore (0..1) for inclusion
}) → Promise<{
  chunks: Array<{
    chunkId: string,
    content: string,                // prepared/cleaned chunk text suitable for prompt insertion
    rawContent?: string,            // optional verbatim source text (when distinct from content)
    headingPath: string[],          // section heading trail from the source document
    pageNumber: number | null,
    documentTitle: string,
    assetId: string,                // host-internal id for the source media asset
    collectionId: string,
    relevanceScore: number,         // 0..1 — host-normalized post-rank score
    vectorDistance?: number,        // pre-rank distance; informational only
  }>,
  sources: Array<{
    sourceId: string,               // stable id for citation (de-duplicated across chunks)
    assetId: string,
    title: string,
    headingPath: string[],
    pageNumber: number | null,
  }>,
  latencyMs?: number,
  hasResults: boolean,
}>

Required methods: retrieve.

Optional methods (host MAY advertise host.knowledge.embed: supported to expose them):

ctx.knowledge.embed({
  texts: string[],
  model?: string,                   // host-allowed embedding model alias
}) → Promise<{
  vectors: number[][],              // one row per input; dimension is host-defined and stable per model
  model: string,
  dimension: number,
}>

RBAC:

  • The host MUST enforce that workspaceId is one the calling run has read access to. Cross-workspace retrieval MUST return 403 knowledge_workspace_forbidden.
  • collectionIds[] MUST be filtered to those visible to the caller; chunks from collections the caller cannot read MUST be omitted, NOT errored on.

Determinism:

  • retrieve is NOT pure — corpus and embeddings change over time. Packs SHOULD treat results as an input snapshot for the current run.
  • Hosts SHOULD include enough metadata (chunkId, assetId, headingPath, pageNumber) for packs to render citations stably.

Failure modes:

  • host_capability_missing
  • knowledge_workspace_forbidden — caller cannot read the workspace
  • knowledge_query_too_long — query exceeds host's embedding-model token limit
  • knowledge_quota_exhausted — workspace-level retrieval quota tripped
  • knowledge_collection_not_found — explicit collectionIds[] includes an id that does not exist for this workspace (vs. a no-access filter, which silently skips)

§host.secrets

Capability flag: secrets.resolveInPack: supported _(advertised via top-level Capabilities.secrets; see capabilities.md §secrets)_

Used by: packs that must call external HTTP APIs requiring stored credentials (e.g., ad-platform APIs, third-party analytics endpoints, vendor-specific SaaS integrations). Current consumers: vendor.myndhyve.ads-publish-meta, vendor.myndhyve.ads-publish-google, vendor.myndhyve.ads-publish-tiktok (the 3 platform-publish packs; the ads.publish.platform umbrella decomposed into platform-specific packs during publish).

Resolves an opaque, host-issued secret reference into plaintext inside the pack process, for the narrow case where a pack needs raw credentials to call an external service that the host doesn't proxy. This is the highest-risk host capability in the spec — every related rule below is a hard requirement, not a recommendation.

ctx.secrets.resolve({
  ref: string,                       // opaque host-issued credential reference (e.g., "secret:tenant:meta-ads-api-token:v3")
  purpose: string,                   // free-form audit string — required (logged by host, NOT by pack)
}) → Promise<{
  plaintext: string,                 // raw credential value; consumed and discarded by the pack — NEVER re-emitted
  expiresAt?: string,                // ISO 8601; pack SHOULD treat as advisory and re-resolve before expiry on long-running calls
  rotatedAt?: string,                // ISO 8601 of last rotation (advisory; for caches that key on rotation epoch)
}>

Required methods: resolve. Hosts that advertise secrets.resolveInPack: supported MUST implement this method AND comply with the redaction invariants below.

Hard rules (extending NFR-7 — Sensitive Data Redaction):

The plaintext returned by ctx.secrets.resolve(...) is the most sensitive value flowing through the pack runtime. Hosts AND packs MUST jointly enforce:

RuleOwnerDetail
Plaintext MUST NOT appear in RunEvent payloadsHostEvent emitter MUST redact secrets.resolve outputs from every serialized event (including node.input / node.output / node.error).
Plaintext MUST NOT appear in OTel spans, log lines, or trace exportsHostTracing adapter MUST scrub. Pack runtime MUST NOT log resolved plaintext via ctx.log.
Plaintext MUST NOT appear in RunSnapshot exports or replay snapshotsHostSnapshot serializer MUST redact. Replay determinism is preserved by replaying the _resolve call_, not by snapshotting the plaintext (host resolves freshly from the credential store on replay).
Plaintext MUST NOT be persisted in pack-side caches across run boundariesPackPack MAY cache within a single ctx.callImageGenerator / fetch() call site for that one invocation. After the call, the plaintext reference MUST be discarded.
Plaintext MUST NOT be sent to any ctx.* method other than the consuming call (e.g., fetch)PackSpecifically: never pass to ctx.callAI, ctx.chat.sendMessage, ctx.canvas.write, or any other host method. The resolution is for direct external HTTP only.
purpose field MUST be present and non-emptyPackHost audit log records {ref, purpose, runId, packName, packVersion, ts}purpose is the required audit breadcrumb.
Lint + redaction unit testsHostHosts that advertise this capability MUST add CI checks verifying plaintext never appears in serialized output across the surfaces above.

Determinism note. ctx.secrets.resolve is non-deterministic by design — the host MAY rotate secrets between runs, MAY return different plaintext on the same ref across runs (rotation), AND MUST NOT snapshot plaintext for replay. Replay-aware hosts SHOULD record only the resolve _call site_ (ref + purpose + ts) and re-resolve from the credential store at replay time. Packs that change behavior based on plaintext content (e.g., parsing a JWT to extract a tenant id) MUST treat the resolved value as run-input that may differ across runs.

RBAC. The host MUST enforce that ref resolves only to credentials the calling run has access to. Refs from another workspace's secret namespace MUST fail with secret_access_denied. Hosts MUST NOT silently substitute a different credential if the requested ref is unavailable.

Failure modes:

  • host_capability_missingctx.secrets.resolve absent (workflow-register-time refusal via peerDependencies: { "secrets.resolveInPack": "supported" } is the correct path; runtime check is defense-in-depth)
  • secret_not_foundref doesn't resolve in the host's credential store
  • secret_access_denied — caller lacks read permission on ref (RBAC denied)
  • secret_revoked — credential was revoked since last successful resolution (advisory: the host MAY surface this as secret_not_found to avoid leaking lifecycle metadata)
  • secret_expired — credential is past its expiry and rotation is required
  • secret_quota_exhausted — host-side rate limit on resolution calls (defense against bulk-leak attacks)

Capability advertisement shape:

{
  "secrets": {
    "supported": true,
    "scopes": ["tenant", "user"],
    "resolution": "host-managed",
    "resolveInPack": "supported"
  }
}

resolveInPack is additive — hosts that omit it advertise only the proxy-flow path (clients pass ai.credentialRef to ctx.callAI; pack-side resolution is unavailable). Hosts that advertise it MUST implement all hard rules above.


§host.knowledge

Capability flag: host.knowledge: supported

Used by: market-intelligence packs, RAG-grounded copy packs, brief-enrichment packs.

Knowledge-base retrieval. Routes queries through the host's RAG pipeline (embedding → vector search → optional re-rank). The host owns the corpus, the embedding model, and the access-control boundary; the pack supplies the query.

ctx.knowledge.retrieve({
  query: string,
  workspaceId?: string,       // omit to use the run's workspace
  collectionIds?: string[],   // scope to specific knowledge collections
  category?: string,          // optional category facet
  candidateLimit?: number,    // pre-rank candidate pool size; host caps the upper bound
  resultLimit?: number,       // post-rank returned chunks; host caps the upper bound
  scoreThreshold?: number,    // minimum relevanceScore (0..1) for inclusion
}) → Promise<{
  chunks: Array<{
    chunkId: string,
    content: string,                // prepared/cleaned chunk text suitable for prompt insertion
    rawContent?: string,            // optional verbatim source text (when distinct from content)
    headingPath: string[],          // section heading trail from the source document
    pageNumber: number | null,
    documentTitle: string,
    assetId: string,                // host-internal id for the source media asset
    collectionId: string,
    relevanceScore: number,         // 0..1 — host-normalized post-rank score
    vectorDistance?: number,        // pre-rank distance; informational only
  }>,
  sources: Array<{
    sourceId: string,               // stable id for citation (de-duplicated across chunks)
    assetId: string,
    title: string,
    headingPath: string[],
    pageNumber: number | null,
  }>,
  latencyMs?: number,
  hasResults: boolean,
}>

Required methods: retrieve.

Optional methods (host MAY advertise host.knowledge.embed: supported to expose them):

ctx.knowledge.embed({
  texts: string[],
  model?: string,                   // host-allowed embedding model alias
}) → Promise<{
  vectors: number[][],              // one row per input; dimension is host-defined and stable per model
  model: string,
  dimension: number,
}>

RBAC:

  • The host MUST enforce that workspaceId is one the calling run has read access to. Cross-workspace retrieval MUST return 403 knowledge_workspace_forbidden.
  • collectionIds[] MUST be filtered to those visible to the caller; chunks from collections the caller cannot read MUST be omitted, NOT errored on.

Determinism:

  • retrieve is NOT pure — corpus and embeddings change over time. Packs SHOULD treat results as an input snapshot for the current run.
  • Hosts SHOULD include enough metadata (chunkId, assetId, headingPath, pageNumber) for packs to render citations stably.

Failure modes:

  • host_capability_missing
  • knowledge_workspace_forbidden — caller cannot read the workspace
  • knowledge_query_too_long — query exceeds host's embedding-model token limit
  • knowledge_quota_exhausted — workspace-level retrieval quota tripped
  • knowledge_collection_not_found — explicit collectionIds[] includes an id that does not exist for this workspace (vs. a no-access filter, which silently skips)

§host.secrets

Capability flag: secrets.resolveInPack: supported _(advertised via top-level Capabilities.secrets; see capabilities.md §secrets)_

Used by: packs that must call external HTTP APIs requiring stored credentials (e.g., ad-platform APIs, third-party analytics endpoints, vendor-specific SaaS integrations). Future consumers: vendor.myndhyve.ads-publish-platform, vendor.myndhyve.ads-metrics-import.

Resolves an opaque, host-issued secret reference into plaintext inside the pack process, for the narrow case where a pack needs raw credentials to call an external service that the host doesn't proxy. This is the highest-risk host capability in the spec — every related rule below is a hard requirement, not a recommendation.

ctx.secrets.resolve({
  ref: string,                       // opaque host-issued credential reference (e.g., "secret:tenant:meta-ads-api-token:v3")
  purpose: string,                   // free-form audit string — required (logged by host, NOT by pack)
}) → Promise<{
  plaintext: string,                 // raw credential value; consumed and discarded by the pack — NEVER re-emitted
  expiresAt?: string,                // ISO 8601; pack SHOULD treat as advisory and re-resolve before expiry on long-running calls
  rotatedAt?: string,                // ISO 8601 of last rotation (advisory; for caches that key on rotation epoch)
}>

Required methods: resolve. Hosts that advertise secrets.resolveInPack: supported MUST implement this method AND comply with the redaction invariants below.

Hard rules (extending NFR-7 — Sensitive Data Redaction):

The plaintext returned by ctx.secrets.resolve(...) is the most sensitive value flowing through the pack runtime. Hosts AND packs MUST jointly enforce:

RuleOwnerDetail
Plaintext MUST NOT appear in RunEvent payloadsHostEvent emitter MUST redact secrets.resolve outputs from every serialized event (including node.input / node.output / node.error).
Plaintext MUST NOT appear in OTel spans, log lines, or trace exportsHostTracing adapter MUST scrub. Pack runtime MUST NOT log resolved plaintext via ctx.log.
Plaintext MUST NOT appear in RunSnapshot exports or replay snapshotsHostSnapshot serializer MUST redact. Replay determinism is preserved by replaying the _resolve call_, not by snapshotting the plaintext (host resolves freshly from the credential store on replay).
Plaintext MUST NOT be persisted in pack-side caches across run boundariesPackPack MAY cache within a single ctx.callImageGenerator / fetch() call site for that one invocation. After the call, the plaintext reference MUST be discarded.
Plaintext MUST NOT be sent to any ctx.* method other than the consuming call (e.g., fetch)PackSpecifically: never pass to ctx.callAI, ctx.chat.sendMessage, ctx.canvas.write, or any other host method. The resolution is for direct external HTTP only.
purpose field MUST be present and non-emptyPackHost audit log records {ref, purpose, runId, packName, packVersion, ts}purpose is the required audit breadcrumb.
Lint + redaction unit testsHostHosts that advertise this capability MUST add CI checks verifying plaintext never appears in serialized output across the surfaces above.

Determinism note. ctx.secrets.resolve is non-deterministic by design — the host MAY rotate secrets between runs, MAY return different plaintext on the same ref across runs (rotation), AND MUST NOT snapshot plaintext for replay. Replay-aware hosts SHOULD record only the resolve _call site_ (ref + purpose + ts) and re-resolve from the credential store at replay time. Packs that change behavior based on plaintext content (e.g., parsing a JWT to extract a tenant id) MUST treat the resolved value as run-input that may differ across runs.

RBAC. The host MUST enforce that ref resolves only to credentials the calling run has access to. Refs from another workspace's secret namespace MUST fail with secret_access_denied. Hosts MUST NOT silently substitute a different credential if the requested ref is unavailable.

Failure modes:

  • host_capability_missingctx.secrets.resolve absent (workflow-register-time refusal via peerDependencies: { "secrets.resolveInPack": "supported" } is the correct path; runtime check is defense-in-depth)
  • secret_not_foundref doesn't resolve in the host's credential store
  • secret_access_denied — caller lacks read permission on ref (RBAC denied)
  • secret_revoked — credential was revoked since last successful resolution (advisory: the host MAY surface this as secret_not_found to avoid leaking lifecycle metadata)
  • secret_expired — credential is past its expiry and rotation is required
  • secret_quota_exhausted — host-side rate limit on resolution calls (defense against bulk-leak attacks)

Capability advertisement shape:

{
  "secrets": {
    "supported": true,
    "scopes": ["tenant", "user"],
    "resolution": "host-managed",
    "resolveInPack": "supported"
  }
}

resolveInPack is additive — hosts that omit it advertise only the proxy-flow path (clients pass ai.credentialRef to ctx.callAI; pack-side resolution is unavailable). Hosts that advertise it MUST implement all hard rules above.


Reserved-but-undocumented surfaces

The following host.* capability slots are reserved for future surfaces. Hosts MUST NOT advertise them until this spec defines the contract.

  • host.media — media asset CRUD (drafted in plans, not yet specified)
  • host.collaboration — multi-user presence + comments
  • host.workspace — workspace metadata + RBAC primitives

A pack that requires a reserved-but-undocumented surface in peerDependencies MUST be rejected at workflow-register time with pack_peer_dependency_undefined.


Capability negotiation

The pack registry's workflow-register handler MUST verify that the host advertises every capability in a pack's peerDependencies block. Specifically:

1. Pack manifest declares peerDependencies: { "host.canvas": "supported", "host.aiEnvelope": "supported" }. 2. Registry fetches the host's /.well-known/openwop (cached per Cache-Control). 3. For each declared peerDependency, the registry checks the capability declaration exists with the required value. 4. If any are missing, register returns 400 pack_peer_dependency_missing with the missing capability list. The pack does NOT register.

Hosts that want to add a new capability surface after publishing a pack MAY:

  • Re-publish the pack manifest with adjusted peerDependencies (creating a new pack version), OR
  • Add the capability declaration to /.well-known/openwop and re-register the existing version.

The wire-shape is additive: removing a declared capability is a breaking change for already-registered packs and SHOULD trigger a major-version bump in the pack.


See also

  • spec/v1/host-extensions.md — namespace rules; this doc is the per-surface contract
  • spec/v1/capabilities.md/.well-known/openwop shape; the host advertises its supported capabilities there
  • spec/v1/node-packs.md §"Manifest format" — peerDependencies syntax
  • spec/v1/agent-memory.mdhost.agentRuntime complements the agents.memoryBackends capability for stateful agents