Operate

Execution Contexts

Why contexts matter, what they change, and how they keep one repo honest across host and container execution.

learnnew usersintermediatestable2026-04-22

Why contexts exist

Contexts tell Ota where a task actually runs.

That is one of the strongest parts of the product because it lets the same repo stay honest about host work, container work, service reachability, and published app URLs without relying on README drift or local conventions.

  • a context chooses the execution plane: host, container, or remote
  • a context can pin the toolchain, lifecycle, and backend-specific requirements for that plane
  • a context can hold plane-wide env defaults so shared tool paths do not have to be repeated on every task
  • a context changes what names and endpoints are reachable, such as localhost on host versus postgres on a Compose network
  • a context gives Ota a real boundary for readiness, task execution, and agent safety instead of making Ota infer it from shell commands

What a context changes

Where

Execution plane

The same task intent can mean host execution, container execution, or remote execution. Contexts make that explicit.

When to use it

Use this when the repo has more than one truthful place a task could run.

What

Toolchain and lifecycle

Contexts hold the runtime requirements, container image, lifecycle, attachments, and context-wide env defaults that belong to one execution plane.

When to use it

Use this when host drift and container reproducibility should be visible in the contract.

How

Reachability and readiness

Contexts decide what a task can reach directly and which service endpoint shape is valid from that plane.

When to use it

Use this when service names, healthchecks, or public app URLs differ between host and container execution.

Default context versus task context

Use execution.default_context when most tasks belong to one execution plane.

Use tasks.<name>.context only when a task should intentionally break from that default.

  • if almost every task runs in app, set execution.default_context: app and stop repeating it
  • keep per-task context only when it changes meaning, such as compose:up on host while builds and tests run in app
  • this keeps contracts shorter and makes the exceptional tasks stand out
Default context firstyaml
execution:  default_context: app  contexts:    host:      backend: native    app:      backend: container      lifecycle: persistent      container:        image: node:24-bookworm tasks:  setup:    internal: true    run: npm install  test:    run: npm test  compose:up:    context: host    run: docker compose up -d

Execution Authoring Patterns

Choose the smallest execution model that stays truthful for your repo.

Use extends as the clean way to share one execution base across multiple named contexts without copying the same container, lifecycle, or requirement shape everywhere.

  • Pattern 1: use single-context shorthand for lean repos where one execution shape is enough.
  • Pattern 2: use named contexts when one repo needs multiple explicit execution boundaries.
  • Pattern 3: use named contexts with extends when multiple contexts share one base shape and only a few fields differ; this is the preferred authoring path once duplication starts to appear.
  • Pattern 1 and patterns 2/3 are mutually exclusive default execution declaration modes; do not mirror the same default execution truth in both places.
  • Resolved inherited contexts are runtime truth, not docs-only sugar: ota run, ota up, ota doctor, and ota execution plan all consume the merged context.
  • Backend-family switches across extends are rejected; keep inheritance within one backend family.
  • extends is additive within one execution family. If a child needs a different backend family, define a separate base instead of inheriting and flipping backend.

Pattern 1: single-context shorthand. Use the shorthand shape when one execution plane is enough and the repo does not need named contexts yet.

Pattern 1yaml
execution:  preferred: container  lifecycle: ephemeral  backends:    container:      image: node:24-bookworm

Pattern 2: named contexts. Use named contexts when one repo has multiple execution planes and each plane should stay explicit.

Pattern 2yaml
execution:  default_context: development  contexts:    development:      backend: container      lifecycle: ephemeral      container:        image: node:24-bookworm    verify:      backend: container      lifecycle: ephemeral      container:        image: node:24-bookworm

Pattern 3: named contexts with extends. Use extends when multiple contexts share one base shape and only a small override set differs.

Pattern 3yaml
execution:  default_context: development  contexts:    node-base:      backend: container      lifecycle: ephemeral      container:        image: node:24-bookworm    development:      extends: node-base      container:        resources:          memory:            minimum: 2GiB            default: 3GiB    verify:      extends: node-base

Invalid: host parent, container child. Ota rejects this because extends is additive within one backend family, not a way to inherit from host and then switch to container.

Invalid exampleyaml
execution:  default_context: app  contexts:    host:      backend: native    app:      extends: host      backend: container      lifecycle: ephemeral      container:        image: node:24-bookworm

When to use mode-aware branches

Sometimes one task intent should stay the same while the execution plane changes.

That is what task-level mode-aware execution is for. It lets ota run start stay one task while --mode native or --mode container selects the right branch honestly.

  • Use one task when operators should remember one intent, like start, build, or test, while backend wiring changes under the hood.
  • Use mode branches when context, env, lifecycle, run/script, or runtime changes by backend and that change should be visible in contract review.
  • Use separate task names only when user-facing intent changes, such as clean:start versus start.
One task, two honest planesyaml
tasks:  start:    requires_services:      - postgres    execution:      default_mode: container      modes:        native:          context: host          env:            DB_URL: jdbc:postgresql://127.0.0.1:5432/app          run: mvn spring-boot:run        container:          context: app          lifecycle: persistent          env:            DB_URL: jdbc:postgresql://postgres:5432/app            SERVER_ADDRESS: 0.0.0.0            SERVER_PORT: "8080"          run: mvn spring-boot:run

Contexts, services, and app URLs

Contexts are where repo readiness stops being abstract.

A service can be ready from one context and unreachable from another. A workload can bind inside a container while Ota still publishes the correct host URL outside it.

  • use services.<name>.endpoints.<context> when the same service is reached differently from host and app planes
  • use services.<name>.readiness.from together with legacy run or structured kind when the readiness probe must run from a specific context
  • use tasks.<name>.requires_services when a task only needs a service ready before it runs
  • use tasks.<name>.runtime.listeners when the task itself is the workload that should publish a host endpoint
Container app plus Compose Postgresyaml
services:  postgres:    manager:      kind: compose      name: local      file: docker-compose.yml      service: postgres    endpoints:      host:        address: 127.0.0.1        port: 5432      app:        address: postgres        port: 5432    readiness:      from: app      run: pg_isready -h postgres -p 5432 tasks:  start:    requires_services:      - postgres    execution:      default_mode: container      modes:        container:          context: app          env:            DB_URL: jdbc:postgresql://postgres:5432/app          run: npm run dev          runtime:            kind: service            listeners:              http:                protocol: http                bind:                  address: 0.0.0.0                  port:                    mode: fixed                    value: 3000                project:                  host:                    address: 127.0.0.1                    port:                      mode: auto                    path: /

Listener shorthand

Listener shorthand is the compact authoring path for common local app ports.

Use it when one task exposes one fixed HTTP or TCP port and the host-visible endpoint should use the same loopback port.

  • shorthand and full listener fields are mutually exclusive on one listener
  • a listener can declare one shorthand protocol, not both http and tcp
  • use the full listener form for container binds, auto host ports, primary listener selection, custom paths, or multiple projected endpoints

Common local HTTP listener

Use this for a local web app, API server, or docs preview that should be reachable at the same fixed loopback port it listens on.

web.http: 3000 expands to an HTTP listener on fixed port 3000 with host projection at http://127.0.0.1:3000/.

HTTP shorthandyaml
tasks:  dev:    run: npm run dev    runtime:      kind: service      readiness:        kind: http        listener: web        path: /      listeners:        web:          http: 3000

Common local TCP listener

Use this when an accepting socket is the correct readiness and reachability signal.

redis.tcp: 6379 expands to a TCP listener on fixed port 6379 with host projection at 127.0.0.1:6379.

TCP shorthandyaml
tasks:  redis:    run: redis-server --port 6379    runtime:      kind: service      readiness:        kind: tcp        listener: redis      listeners:        redis:          tcp: 6379

Full HTTP listener form

This is the full listener shape behind the HTTP shorthand.

Use it directly when bind address, projected host address, host port mode, primary listener selection, or path behavior needs to be explicit.

Expanded listener formyaml
listeners:  web:    protocol: http    bind:      address: 127.0.0.1      port:        mode: fixed        value: 3000    project:      host:        address: 127.0.0.1        port:          mode: fixed          value: 3000        path: /

Task target bindings

Use tasks.<name>.targets.<target> when one task should point at either another repo-managed app by service identity or one explicit declared URL.

Keep the topology truth in targets. Use operator input only when the task genuinely needs an override channel.

  • targets.<name> is the consumer-local target name; keep it short and stable because ota uses it in receipts and, without an override input, exports it as OTA_TARGET_<TARGET>
  • choose exactly one identity shape per target: service for repo-managed topology or url for one fixed declared URL
  • use targets.<name>.service.task to name the producer task that owns the service you want to follow; the value must be an existing service task in the same contract unless you also declare service.member for a monorepo member producer or service.repo for a workspace repo producer
  • use targets.<name>.service.member only for monorepo member producers declared under workspace.members; today that cross-member slice stays intentionally narrow: address_view: host stays activation.mode: manual only, while address_view: topology / address_view: internal work only when consumer and producer share one declared backend binding on the active plane and are the only member-target shapes that support ensure_started / restart_ready / ensure_running / ensure_ready
  • use targets.<name>.service.repo when the producer lives in another repo declared under ota.workspace.yaml; the current shipped cross-repo slice stays explicit: only address_view: host is supported, the producer listener must declare one fixed project.host endpoint, and host-view activation may reuse or start that producer through the owning repo contract before the consumer runs
  • use targets.<name>.service.listener to pick the exact producer listener to target; the value must be one of that producer's declared runtime listeners, but you may omit it when the producer exposes exactly one declared listener name
  • use targets.<name>.service.address_view to choose the reachable address shape: host, topology, or internal
  • use address_view: host when the consumer may run in a different execution plane and should target the producer's published host URL
  • use address_view: topology only when ota can truthfully resolve the current local topology address for that task shape
  • use address_view: internal for direct internal-plane addressing when caller and producer share one declared backend boundary on the active plane; unresolved cases still fail clearly today
  • use override_input only when the operator may intentionally point the task at staging, preview, another local service, or another explicit URL; the value must name an input on the same task
  • use activation.mode: manual when target resolution is enough, ensure_started when ota should hand producer startup off immediately, restart_ready when ota should bounce a reachable producer and verify it again, ensure_running when ota should make the declared listener reachable first, and ensure_ready only when ota should also wait for a deeper declared readiness contract; url targets stay manual
  • today non-manual activation is intentionally narrow: explicit overrides skip producer startup, compatibility defaults fail clearly, and actual producer auto-start is limited to producer shapes ota can own honestly: persistent container producer services, unix native producer services, built-in remote producer services only when caller and producer share one declared remote backend binding, and backend-provider remote producer services only when the matching backend_provider extension declares activation.provider_managed_cleanup: true; built-in shared-remote address_view: host / address_view: topology / address_view: internal may probe the remote plane, and backend-provider remote activation now covers those same shared-remote views when ota can issue provider-owned cleanup first; ensure_started hands startup off immediately, restart_ready can bounce a reachable producer before waiting again, ensure_running observes listener reachability, and ensure_ready may observe tcp or http readiness
  • when the producer service task declares runtime.readiness, ota waits for that readiness contract instead of treating an open listener socket as sufficient
  • run summaries describe activation in plain terms: started_started / reused_started talk about startup handoff, started_running / reused_running talk about listener reachability, and restarted_ready / started_ready / reused_ready talk about deeper readiness
  • stream-mode runs show an explicit activation wait phase while ota is starting or waiting on the producer readiness contract
  • if activation started the producer for this run, ota cleans it up on interrupt; reused producers are left running intentionally
  • when override_input is present, ota resolves the target into that input unless the operator passed an explicit value
  • when override_input is omitted, ota exports the resolved URL as OTA_TARGET_<TARGET> so the task still gets a real runtime value
One producer, two consumersyaml
tasks:  dev:    run: pnpm dev:api    runtime:      kind: service      listeners:        http:          protocol: http          bind:            address: 127.0.0.1            port:              mode: fixed              value: 8080          project:            host:              address: 127.0.0.1              primary: true              port:                mode: fixed                value: 8080              path: /   sandbox:    inputs:      base_url:        description: Override the API base URL when needed    targets:      api:        service:          task: dev          listener: http          address_view: host        override_input: base_url        activation:          mode: ensure_ready    run: pnpm dev:sandbox   probe:api:    targets:      api:        service:          task: dev          listener: http          address_view: host    run: curl -fsS "$OTA_TARGET_API/health"

What breaks without context

If the same repo mixes host tools and container workloads, missing context information creates subtle, environment-dependent failures.

The same task appears to pass in one terminal and fail in another because execution path and endpoint assumptions are hidden.

  • A localhost database URL in one plane can succeed on host but fail in a container workload where only postgres DNS is available.
  • A containerized startup command that opens 127.0.0.1:3000 may print an unreachable URL if the workload endpoint was never projected to a host-mapped port.
  • Service readiness checks may pass on host, but still fail during container work if probe context, manager control plane, and endpoints are not explicitly aligned.
Without context: silent mismatchyaml
tasks:  dev:    run: |      ./gradlew bootRun    # DB URL looks like localhost, but this depends on where the task runs.    env:      DB_URL: jdbc:postgresql://localhost:5432/app    runtime:      kind: service      listeners:        http:          protocol: http          bind:            address: 127.0.0.1            port:              mode: fixed              value: 3000          project:            host:              address: 127.0.0.1              port:                mode: fixed                value: 3000
With context: honest topologyyaml
execution:  default_context: app  contexts:    host:      backend: native    app:      backend: container      lifecycle: persistent      container:        image: maven:3.9.14-eclipse-temurin-21-noble services:  postgres:    manager:      kind: compose      name: local      file: docker-compose.yml      service: postgres    endpoints:      host:        address: 127.0.0.1        port: 5432      app:        address: postgres        port: 5432    readiness:      from: app      run: pg_isready -h postgres -p 5432 tasks:  dev:    context: app    requires_services:      - postgres    env:      DB_URL: jdbc:postgresql://postgres:5432/app    runtime:      kind: service      listeners:        http:          protocol: http          bind:            address: 0.0.0.0            port:              mode: fixed              value: 3000          project:            host:              address: 127.0.0.1              port:                mode: auto              path: /

Use this shape when the task should stay inspectable as a packaged command or packaged container runtime instead of collapsing back into shell.

Structured launch exampleyaml
tasks:  quickstart:    description: Start n8n through a packaged command    launch:      kind: command      exe: npx      args: [n8n]    runtime:      kind: service      surfaces:        - backend   packaged:    description: Start n8n through a packaged container image    launch:      kind: container      image: docker.n8n.io/n8nio/n8n      volumes:        - name: n8n_data          target: /home/node/.n8n    runtime:      kind: service      surfaces:        backend:          bind:            address: 0.0.0.0            port:              mode: fixed              value: 5678          project:            host:              address: 127.0.0.1              port:                mode: fixed                value: 5678              path: /              primary: true

Host setup, container app

Keep Docker and Compose on the host while app work happens in a container context.

Use this when host orchestration and containerized app execution both need to stay explicit and truthful.

Host setup, container appyaml
execution:  default_context: app  contexts:    host:      backend: native    app:      backend: container      lifecycle: persistent      container:        image: node:24-bookworm tasks:  compose:up:    context: host    run: docker compose up -d  dev:    context: app    run: npm run dev

One app, two truthful start paths

Keep one user intent (start) while behavior shifts by backend.

Use this when operators need stable task naming across host and container execution.

One app, two truthful start pathsyaml
tasks:  start:    requires_services:      - postgres    execution:      default_mode: container      modes:        native:          context: host          env:            DB_URL: jdbc:postgresql://127.0.0.1:5432/app          run: ./gradlew bootRun        container:          context: app          lifecycle: persistent          env:            DB_URL: jdbc:postgresql://postgres:5432/app          run: ./gradlew bootRun

Full listener form: container bind, host URL

Keep container bind behavior stable while Ota publishes a host URL users can open.

Use the full listener form when a container workload needs deterministic internal networking, auto/fixed host projection, primary listener selection, or user-facing reachability that cannot be represented by shorthand.

Container bind, host URLyaml
tasks:  dev:    context: app    lifecycle: persistent    run: npm run dev    runtime:      kind: service      listeners:        http:          protocol: http          bind:            address: 0.0.0.0            port:              mode: fixed              value: 3000          project:            host:              address: 127.0.0.1              port:                mode: auto              path: /

Container memory, one-run override

Keep container memory policy in context and still allow one-run overrides.

Use this when local reliability depends on higher than default memory for a specific run.

Container memory, one-run overrideyaml
execution:  contexts:    app:      backend: container      lifecycle: persistent      container:        image: node:24-bookworm        resources:          memory:            minimum: 2GiB            default: 2GiB tasks:  test:    context: app    run: npm test

Decision guide

  • Set execution.default_context when one backend plane is the default operating mode for most tasks.
  • Use execution.contexts.<name>.extends when two contexts share one base shape and only differ in a small override set.
  • Set per-task context only when a task truly requires a different execution plane for correct behavior.
  • Choose mode-aware execution when one user intent must remain stable across more than one plane.
  • Keep clean:start separate from start only when the operation itself changes, not just where it runs.
  • When a mode cannot be resolved, fail clearly instead of silently choosing an alternate context.