Connections

Your agent can call create_pull_request directly today. Connections plus a guard are how you put that behind a tests-passed gate — wiring in MCP servers, CLI commands, and REST APIs as backends.

Your coding agent — Claude Code or Cursor — can call create_pull_request directly today, the moment its raw output looks plausible, no test evidence required. Connections are how you put that move behind a tests-passed gate.

A connection is a backend the gateway talks to: a CLI that runs npm test, the GitHub MCP server that opens the PR. You declare each backend once in the connections block, then a guarded capability wires them together — the PR move only fires when the test move has produced its evidence. Here’s the shape of it before the syntax:

proxy:
  expose:
    - name: safe.create_pr
      description: Open a PR — only when tests have passed.
      guards:
        - kind: evidence
          requires: [tests_passed]      # no green suite, no PR
      executor:
        kind: mcp
        connection: github             # declared below
        tool: create_pull_request

The agent can’t open the PR by asking nicely or by being confident. The tests_passed evidence has to exist first, and that evidence comes from a different connection — a CLI that ran your real suite. The rest of this guide is the three connection kinds that make that wiring possible: MCP servers, CLI commands, and REST APIs.

MCP connections

MCP connections speak the Model Context Protocol to another server. You can connect via stdio (launching a child process) or via Streamable HTTP (connecting to a running server).

Stdio (child process)

The gateway spawns the process and communicates over stdin/stdout. This works with any MCP server – native binaries, npm packages via npx, Python packages via uvx, Docker containers, anything.

connections:
  # Native binary on PATH
  github:
    kind: mcp
    command: github-mcp-server

  # npm-distributed MCP server via npx
  filesystem:
    kind: mcp
    command: npx
    args: [-y, "@modelcontextprotocol/server-filesystem", "/tmp"]

  # Python-distributed MCP server via uvx
  fetch:
    kind: mcp
    command: uvx
    args: [mcp-server-fetch]

  # Containerized MCP server
  postgres:
    kind: mcp
    command: docker
    args: [run, -i, --rm, -e, DATABASE_URL, mcp/postgres:latest]
    env:
      DATABASE_URL: postgres://localhost/app

The env block passes environment variables to the child process. The gateway doesn’t care what’s inside the container or package – if it speaks MCP over stdio, it works.

Streamable HTTP

For MCP servers already running somewhere, connect via URL:

connections:
  remote_server:
    kind: mcp
    url: https://mcp.example.com/v1

No command needed – the gateway connects to the existing server. Use this for shared MCP services, cloud-hosted servers, or anything you don’t want the gateway to manage the lifecycle of.

CLI connections

CLI connections run shell commands. The gateway spawns the process, passes arguments, and captures stdout. This is the kind that runs your test suite — the move that earns the evidence to open a PR.

connections:
  cargo:
    kind: cli
    command: cargo
    workingDirectory: /repo

  shell:
    kind: cli
    command: /bin/bash
    env:
      PATH: /usr/local/bin:/usr/bin

The command is the binary to run (cargo, npm, pytest, /bin/bash). args on the executor (not the connection) supply the specific arguments for each call — so one cargo connection serves both cargo test and cargo build. workingDirectory sets where the process runs. env passes environment variables.

REST connections

REST connections make HTTP requests to any API.

connections:
  github_api:
    kind: rest
    baseUrl: https://api.github.com
    headers:
      Authorization: "Bearer ${GITHUB_TOKEN}"
      Accept: application/vnd.github+json

  ci:
    kind: rest
    baseUrl: https://api.your-ci.example.com
    headers:
      Authorization: "Bearer ${CI_TOKEN}"

The baseUrl is the root of the API. headers are sent with every request. Notice ${GITHUB_TOKEN} – environment variable interpolation works in header values, so you don’t put secrets directly in your config file.

Importing tools from MCP servers

The proxy.import block discovers tools from MCP connections at startup and turns each one into a proxy capability automatically. You don’t have to declare each tool by hand.

proxy:
  import:
    - connection: github
      prefix: github
      include: [list_issues, create_issue, create_pull_request]
      tags: [github, source-control]

    - connection: filesystem
      prefix: fs
      tags: [filesystem]

    - connection: postgres
      prefix: pg
      include: [query, schema]
      exclude: [drop_table]
      tags: [database, sql]

The gateway calls tools/list on each connection at startup and creates proxy exposures for the matching tools. Each imported tool gets:

include – only import these tools (allowlist). If omitted, all tools are imported.

exclude – skip these tools (blocklist). Applied after include.

prefix – prepended to each tool name with a dot separator. Keeps names from colliding when you import from multiple servers.

tags – applied to all imported tools. Makes them findable in praxec.query({query}) search.

You can mix imports with explicit declarations. Declared capabilities can carry guards, reliability policies, and audit hooks that imports don’t have by default:

proxy:
  import:
    - connection: github
      prefix: github
      tags: [github]

  expose:
    - name: safe.create_pr
      description: Create a PR with test evidence required.
      guards:
        - kind: evidence
          requires: [tests_passed]
      executor:
        kind: mcp
        connection: github
        tool: create_pull_request

Exposing capabilities manually

The proxy.expose block lets you declare capabilities with full control over their schema, guards, reliability, and executor.

With an inline executor

proxy:
  expose:
    - name: github.list_issues
      title: List GitHub issues
      description: List issues from a GitHub repository.
      tags: [github, issues, read]
      aliases: [issues, bugs]
      inputSchema:
        type: object
        required: [repo]
        properties:
          repo: { type: string }
      executor:
        kind: mcp
        connection: github
        tool: list_issues

With a capability reference

Define the capability once, expose it (potentially with extra guards or a different name):

capabilities:
  raw.create_pr:
    title: Create GitHub PR
    executor:
      kind: mcp
      connection: github
      tool: create_pull_request

  safe.create_pr:
    wraps: raw.create_pr
    guards:
      - kind: evidence
        requires: [tests_passed]

proxy:
  expose:
    - capability: safe.create_pr
      as: github.create_pr
      tags: [github, write]
      aliases: [pr, pull-request]

The wraps keyword stacks guards – safe.create_pr inherits the executor from raw.create_pr and adds its own evidence guard on top. The as field renames it for the proxy surface.

Executor kinds

Executors are the actual work that happens when a transition fires. They live inside proxy.expose[].executor, transition executor, state onEnter.executor, and fallback executors[]. The full set of kinds includes mcp, cli, rest, human, noop, workflow, script, parallel, and llm — see the Executors reference for the complete catalog. This section covers the most common ones.

MCP executor

Calls a tool on a connected MCP server.

executor:
  kind: mcp
  connection: github
  tool: create_pull_request
  map:
    title: "$.arguments.title"
    head: "$.context.branch_name"
    base: main

The map block feeds values into the tool call – paths resolve from context (the shared state your workflow carries across moves) and from the call’s arguments; bare strings are literals. The first call to a connection opens it and the gateway holds it open, so repeated calls to the same MCP server reuse that one connection instead of reconnecting each time.

CLI executor

Runs a shell command and captures stdout as JSON.

executor:
  kind: cli
  connection: dotnet
  args:
    - test
    - "$.arguments.project"

Arguments with $. prefixes are interpolated from the workflow’s scopes. Everything else is passed verbatim. The executor captures stdout and attempts to parse it as JSON for output mapping.

The treatNonZeroAsFailure flag (default true) controls what happens on non-zero exit codes. Set it to false when the exit code is data, not an error – useful for test runners and validators where you want to branch on the result:

executor:
  kind: cli
  connection: shell
  args: ["-c", "cargo test"]
  treatNonZeroAsFailure: false

REST executor

Makes HTTP requests to any API.

executor:
  kind: rest
  connection: github_api
  method: POST
  path: "/repos/{owner}/{repo}/pulls"
  query: { state: open }
  headers: { X-Custom: value }
  body:
    title: "$.arguments.title"
    head: "$.arguments.head"
    base: main

path supports {var} templating – variables pull from arguments, then context, then workflow input. query, headers, and body all support path expressions. Status codes 408, 429, and 5xx are classified as retryable errors.

Human executor

Stops the chain and queues for human approval — the way you keep “merge to main” a move the agent can open but never fire on its own.

executor:
  kind: human
  queue: pr-reviews

Doesn’t execute anything. Records a human.approval.requested audit event, returns a pending status, and waits. A person resolves it through your approval integration. The queue label tells your tooling where to route the request.

Noop executor

Returns immediately with an empty result.

executor:
  kind: noop

Useful for testing, stubs, and transitions that just move state without doing work. When a proxy.expose entry has no executor, noop is the default.

Workflow executor

Starts a sub-workflow and waits for it to complete.

executor:
  kind: workflow
  definitionId: validation_pipeline
  input:
    artifact: "$.context.artifactId"
  timeoutMs: 300000

The sub-workflow runs to completion, and its final context becomes the executor’s output. This lets you compose workflows – a ship workflow can kick off a validation sub-workflow (lint, test, type-check) and use its results to decide whether the PR move becomes reachable.

Putting it together

Back to the opening problem. Here’s a complete config that gates your agent’s create_pull_request behind a real test run, using all three connection kinds — a cli that runs the suite, a github MCP server that opens the PR, and a rest connection to GitHub’s API for a status check:

version: "1.0.0"

connections:
  # CLI: runs your real test suite (npm, cargo, pytest …)
  shell:
    kind: cli
    command: /bin/bash

  # MCP: the GitHub MCP server that can open PRs
  github:
    kind: mcp
    command: github-mcp-server

  # REST: GitHub's HTTP API, for a commit-status read
  github_api:
    kind: rest
    baseUrl: https://api.github.com
    headers:
      Authorization: "Bearer ${GITHUB_TOKEN}"

audit:
  sink: stderr

proxy:
  expose:
    # The test gate. treatNonZeroAsFailure keeps a red suite a failure,
    # so this move only produces tests_passed evidence when it's green.
    - name: run_tests
      description: Run the test suite for the current branch.
      tags: [tests, ci]
      executor:
        kind: cli
        connection: shell
        args: ["-c", "npm test"]
      reliability:
        timeoutMs: 120000

    # The guarded write. No tests_passed evidence → GUARD_REJECTED.
    - name: safe.create_pr
      description: Open a PR — only when tests have passed.
      tags: [github, write]
      inputSchema:
        type: object
        required: [title, head]
        properties:
          title: { type: string }
          head: { type: string }
      guards:
        - kind: evidence
          requires: [tests_passed]
      executor:
        kind: mcp
        connection: github
        tool: create_pull_request
        map:
          title: "$.arguments.title"
          head: "$.arguments.head"
          base: main

Three connection kinds — cli, mcp, rest — wired through the same two tools the agent ever sees. The agent searches for “open a pull request” through praxec.query({query}), finds safe.create_pr, and tries to fire it. With no tests_passed evidence on the workflow, that comes back GUARD_REJECTED — distinct from the INVALID_TRANSITION you’d get for a move that doesn’t exist in the current state at all. It can’t talk its way past the guard; it has to run run_tests green first. The whole point: the wrong order isn’t a thing the agent can do, no matter what the system prompt does or doesn’t say. (See How to run it for how airtight that is in each run mode.)