Building Port Adapters

Port adapters let non-MCP applications route through the PRECINCT gateway's full 13-layer security stack (identity, policy, DLP, audit, rate limiting) without reimplementing any of it. This guide walks through the complete adapter architecture using the reference adapter (from the case study) as a worked example.

Prerequisite

Read the Integration Guide first to understand SPIFFE identity registration, tool setup, and OPA policy configuration. This guide assumes your workload already has a SPIRE entry and can reach the gateway.

When You Need an Adapter

Not every integration requires a port adapter. PRECINCT offers three integration paths, each appropriate for a different situation. Use the simplest one that meets your needs.

1. MCP-Native

Your application already speaks MCP (Model Context Protocol). Point it at the PRECINCT gateway as an MCP proxy. All 13 middleware layers apply automatically. No code needed.

Use when: the client supports MCP natively (e.g., Claude Desktop, Cursor, any MCP-compliant agent).

2. SDK

Use the Python or Go SDK to call the gateway's JSON-RPC API directly from your application code. The SDK handles SPIFFE identity injection, error mapping, and retry logic.

Use when: you control the application code and want to add individual tool calls or model requests behind the gateway.

3. Port Adapter

Write a port adapter when your application has its own HTTP/WebSocket/webhook protocol that differs from MCP. The adapter translates between your protocol and the gateway's internal plane APIs.

Use when: the application has a pre-existing wire format (e.g., OpenAI Responses API, custom WebSocket RPC, platform webhooks).

Decision rule: If you can use MCP directly, do that. If you can embed SDK calls, do that. Build an adapter only when you need to front a non-MCP protocol with the full security stack.

Architecture Overview

A port adapter sits between the external application and the gateway's internal processing pipeline. The gateway's HTTP handler tries each registered adapter in order; the first adapter whose TryServeHTTP returns true claims the request. Unclaimed requests fall through to the default MCP handler.

graph LR A["External App"] -->|"Custom Protocol"| B["Port Adapter"] B -->|"PortGatewayServices"| C["Gateway Internals"] C --> D["13-Layer Middleware"] D --> E["Upstream / Egress"] subgraph "Port Adapter Boundary" B end subgraph "Gateway" C D end

The adapter never touches the middleware chain directly. Instead, it calls methods on the PortGatewayServices facade to build plane requests, evaluate policy, execute egress, write audit logs, and handle errors. The facade delegates to the same internal functions that the MCP path uses, so every adapter gets identical security enforcement.

Request Dispatch Flow

sequenceDiagram participant Client participant Gateway participant Adapter participant Services as PortGatewayServices participant Upstream Client->>Gateway: HTTP Request Gateway->>Adapter: TryServeHTTP(w, r) alt Path matches Adapter->>Services: BuildModelPlaneRequest() Adapter->>Services: EvaluateModelPlaneDecision() alt Decision = allow Adapter->>Services: ExecuteModelEgress() Services->>Upstream: Proxied request Upstream-->>Services: Response Services-->>Adapter: ModelEgressResult Adapter->>Services: LogPlaneDecision() Adapter-->>Client: Translated response else Decision = deny Adapter->>Services: LogPlaneDecision() Adapter-->>Client: Error response end Adapter-->>Gateway: return true else Path does not match Adapter-->>Gateway: return false Gateway->>Gateway: Try next adapter or MCP handler end

The PortAdapter Interface

The contract for a port adapter is deliberately minimal. It consists of two methods defined in POC/internal/gateway/port.go:

// PortAdapter is the extension point for third-party agent integrations.
// Each adapter claims a set of URL paths and handles matching requests.
type PortAdapter interface {
    // Name returns a short, unique identifier for the port.
    Name() string

    // TryServeHTTP inspects the request and, if the path belongs to this port,
    // serves it and returns true. Returns false if the request is not handled.
    TryServeHTTP(w http.ResponseWriter, r *http.Request) bool
}

Contract Rules

  • Name() must return a unique string identifier. It is used in logging and metrics. The reference adapter returns "openclaw".
  • TryServeHTTP must inspect r.URL.Path and return false if the path does not belong to this adapter. If the path matches, handle the full request and return true. Never write to w when returning false.
  • Use a compile-time check to verify your struct satisfies the interface: var _ gateway.PortAdapter = (*Adapter)(nil)

Reference Adapter Example: TryServeHTTP Dispatch

func (a *Adapter) TryServeHTTP(w http.ResponseWriter, r *http.Request) bool {
    if r == nil || r.URL == nil {
        return false
    }

    // WS path
    if r.URL.Path == openClawWSPath {
        a.handleWSEntry(w, r)
        return true
    }

    // Webhook paths
    if strings.HasPrefix(r.URL.Path, webhookBasePath) {
        a.handleWebhook(w, r)
        return true
    }

    // HTTP paths
    switch r.URL.Path {
    case protocol.ResponsesPath:
        a.handleResponses(w, r)
        return true
    case protocol.ToolsInvokePath:
        a.handleToolsInvoke(w, r)
        return true
    default:
        return false
    }
}

The dispatch is a simple path-matching switch. Each matched path delegates to a dedicated handler method. Unrecognized paths return false to let the gateway try the next adapter or fall through to the default MCP handler.

The PortGatewayServices Facade

The PortGatewayServices interface is the narrow API surface that adapters use to access gateway internals. It is defined in POC/internal/gateway/port.go and implemented by the *Gateway struct in port_services.go.

Model Plane

// Build a PlaneRequestV2 from an HTTP request and OpenAI-compatible payload.
BuildModelPlaneRequest(r *http.Request, payload map[string]any) PlaneRequestV2

// Evaluate model-plane policy. Returns (decision, reason, httpStatus, metadata).
EvaluateModelPlaneDecision(r *http.Request, req PlaneRequestV2) (Decision, ReasonCode, int, map[string]any)

// Execute model egress: proxy to the upstream LLM provider.
ExecuteModelEgress(ctx context.Context, attrs map[string]any,
    payload map[string]any, authHeader string) (*ModelEgressResult, error)

// Check if policy-intent projection is enabled.
ShouldApplyPolicyIntentProjection() bool

Tool Plane

// Evaluate a tool request against the tool-plane policy engine.
EvaluateToolRequest(req PlaneRequestV2) ToolPlaneEvalResult

Messaging Egress

// Execute messaging egress to a platform (WhatsApp, Telegram, Slack).
ExecuteMessagingEgress(ctx context.Context, attrs map[string]string,
    payload []byte, authHeader string) (*MessagingEgressResult, error)

// Redeem a SPIKE secret token. Used for per-message token resolution
// in WebSocket frames that bypass the HTTP middleware chain.
RedeemSPIKESecret(ctx context.Context, tokenStr string) (string, error)

Audit and Logging

// Log a plane decision with full correlation metadata.
LogPlaneDecision(r *http.Request, decision PlaneDecisionV2, httpStatus int)

// Write a structured audit event.
AuditLog(event middleware.AuditEvent)

Error Writing

// Write a structured v24 gateway error response.
WriteGatewayError(w http.ResponseWriter, r *http.Request,
    httpCode int, errorCode string, message string,
    middlewareName string, reason ReasonCode, details map[string]any)

Approval Capabilities

// Validate and consume a one-time step-up approval token.
ValidateAndConsumeApproval(token string, scope middleware.ApprovalScope) (
    *middleware.ApprovalCapabilityClaims, error)

// Check if the approval capabilities service is configured.
HasApprovalService() bool

Connector Conformance

// Validate a connector's identity and signature via CCA runtime check.
// Returns (allowed, reason).
ValidateConnector(connectorID, signature string) (bool, string)

Exported Helpers

The port.go file also exports several utility functions for use by adapters:

// Correlation IDs from request context.
GetDecisionCorrelationIDs(r *http.Request, env RunEnvelope) (traceID, decisionID string)

// Merge metadata maps (non-destructive).
MergeMetadata(base, extra map[string]any) map[string]any

// Safe string extraction with fallback.
GetStringAttr(attrs map[string]any, key, fallback string) string
DefaultString(v, fallback string) string

// Policy-intent projection helpers.
BuildModelPolicyIntentProjection(attrs map[string]any, envelope RunEnvelope) string
PrependSystemPolicyIntentMessage(payload map[string]any, projection string) bool
ProjectionHeaderValue(enabled, applied bool) string

// Header and status helpers.
CopyHeaderIfPresent(dst, src http.Header, key string)
StatusForToolReason(reason ReasonCode) int

Protocol Layer Design

A well-structured adapter separates protocol types (wire-format parsing and serialization) from gateway interaction (policy evaluation, egress execution). The reference adapter places all protocol types and parsing functions in a dedicated protocol/ subpackage.

Why Separate?

Keeping protocol types in their own package lets you unit-test parsing logic in isolation (no gateway dependency) and swap wire formats without changing the adapter's gateway interaction code.

Key Protocol Types

From POC/ports/openclaw/protocol/http_adapter.go:

const (
    ResponsesPath   = "/v1/responses"
    ToolsInvokePath = "/tools/invoke"
)

type ResponsesRequest struct {
    Model           string
    Messages        []ResponseMessage
    Instructions    string
    Stream          bool
    User            string
    MaxOutputTokens int
}

type ResponseMessage struct {
    Role    string
    Content string
}

type ToolsInvokeRequest struct {
    Tool          string
    Action        string
    Args          map[string]any
    SessionKey    string
    DryRun        bool
    ApprovalToken string
}

type ToolPolicyTarget struct {
    Action       string
    Resource     string
    CapabilityID string
    Adapter      string
}

Protocol Functions

The protocol package also provides pure parsing and translation functions that contain no gateway dependencies:

// Parse raw JSON into a typed ResponsesRequest.
func ParseResponsesRequest(raw []byte) (ResponsesRequest, error)

// Convert ResponsesRequest messages into OpenAI-compatible format.
func BuildOpenAIMessages(req ResponsesRequest) []map[string]any

// Parse raw JSON into a typed ToolsInvokeRequest.
func ParseToolsInvokeRequest(raw []byte) (ToolsInvokeRequest, error)

// Map a tool invocation to its policy target (action, resource, capability).
func ResolveToolPolicyTarget(req ToolsInvokeRequest) ToolPolicyTarget

// Check if a tool is on the dangerous-tool blocklist for HTTP.
func IsDangerousHTTPTool(tool string) bool

Dangerous Tool Blocklist

The protocol layer defines a set of tools that are blocked on the HTTP wrapper path for safety. These tools are only permitted through the WebSocket control plane where session-level authorization applies:

var dangerousHTTPToolSet = map[string]struct{}{
    "sessions_spawn": {},
    "sessions_send":  {},
    "gateway":        {},
    "whatsapp_login": {},
    "exec":           {},
    "shell":          {},
    "bash":           {},
}

Building an HTTP Handler

HTTP handlers are the most common adapter entry point. The reference adapter exposes two HTTP routes: /v1/responses for the model plane and /tools/invoke for the tool plane.

Model Plane: /v1/responses

The model-plane handler follows a 10-step flow. Each step calls a PortGatewayServices method rather than reimplementing the logic:

Model plane handler flow steps
Step Action Facade Method
1Validate HTTP method (POST only)WriteGatewayError
2Read and parse request bodyprotocol.ParseResponsesRequest
3Reject stream=true (unsupported on secure wrapper)WriteGatewayError
4Translate to OpenAI-compatible payloadprotocol.BuildOpenAIMessages
5Build the model-plane requestBuildModelPlaneRequest
6Evaluate model-plane policy (OPA, DLP, identity)EvaluateModelPlaneDecision
7Apply policy-intent projection (if enabled)BuildModelPolicyIntentProjection / PrependSystemPolicyIntentMessage
8Execute egress to upstream LLM providerExecuteModelEgress
9Log the plane decision with correlation IDsLogPlaneDecision
10Translate response to application format, set correlation headers(adapter-specific)

Correlation Headers

Every response from the adapter must include PRECINCT correlation headers so that clients and auditors can trace decisions back to specific policy evaluations:

w.Header().Set("X-Precinct-Decision-ID", decisionID)
w.Header().Set("X-Precinct-Trace-ID", traceID)
w.Header().Set("X-Precinct-Reason-Code", string(finalReason))
w.Header().Set("X-Precinct-Provider-Used", egress.ProviderUsed)
w.Header().Set("X-Precinct-Policy-Intent-Projection",
    gateway.ProjectionHeaderValue(projectionEnabled, projectionApplied))

Tool Plane: /tools/invoke with Step-Up Approval

The tool-plane handler adds step-up approval support. When the policy engine returns RequireStepUp: true, the adapter checks for an approval token in the request and validates it:

func (a *Adapter) evaluateToolRequest(req gateway.PlaneRequestV2) gateway.ToolPlaneEvalResult {
    eval := a.gw.EvaluateToolRequest(req)
    if !eval.RequireStepUp {
        return eval
    }

    // Look for approval token in multiple field names.
    token := strings.TrimSpace(
        gateway.GetStringAttr(attrs, "approval_capability_token", ""))
    if token == "" {
        token = strings.TrimSpace(
            gateway.GetStringAttr(attrs, "step_up_token", ""))
    }

    if token == "" {
        eval.Metadata = gateway.MergeMetadata(eval.Metadata,
            map[string]any{"step_up_state": "missing_token"})
        return eval
    }
    if !a.gw.HasApprovalService() {
        eval.Metadata = gateway.MergeMetadata(eval.Metadata,
            map[string]any{"step_up_state": "approval_service_unavailable"})
        return eval
    }

    _, err := a.gw.ValidateAndConsumeApproval(token, middleware.ApprovalScope{
        Action:        strings.TrimSpace(req.Policy.Action),
        Resource:      strings.TrimSpace(req.Policy.Resource),
        ActorSPIFFEID: req.Envelope.ActorSPIFFEID,
        SessionID:     req.Envelope.SessionID,
    })
    if err != nil {
        eval.Metadata = gateway.MergeMetadata(eval.Metadata,
            map[string]any{"step_up_state": "invalid_or_expired_token"})
        return eval
    }

    eval.RequireStepUp = false
    eval.Decision = gateway.DecisionAllow
    eval.Reason = gateway.ReasonToolAllow
    eval.HTTPStatus = gateway.StatusForToolReason(gateway.ReasonToolAllow)
    eval.Metadata = gateway.MergeMetadata(eval.Metadata,
        map[string]any{"step_up_state": "approved_token_consumed"})
    return eval
}

Building a WebSocket Handler

WebSocket adapters are more complex than HTTP because they maintain session state across multiple frames. The reference adapter implements a full WS RPC protocol at /openclaw/ws.

Connection Lifecycle

sequenceDiagram participant Client participant Adapter as WS Adapter participant Services as PortGatewayServices Client->>Adapter: WS Upgrade Adapter->>Adapter: Validate upgrade, spawn goroutine Client->>Adapter: {"type":"req", "method":"connect", ...} Adapter->>Adapter: Validate SPIFFE ID, role, scopes Adapter-->>Client: {"type":"res", "ok":true, "payload":{"type":"hello-ok",...}} loop Per-message dispatch Client->>Adapter: {"type":"req", "method":"message.send", ...} Adapter->>Adapter: wsAllowed(session, method)? alt Allowed Adapter->>Services: EvaluateToolRequest() Adapter->>Services: RedeemSPIKESecret() (if auth_ref) Adapter->>Services: ExecuteMessagingEgress() Adapter-->>Client: {"type":"res", "ok":true, ...} else Forbidden Adapter-->>Client: {"type":"res", "ok":false, "error":{...}} end end

Frame Schema

The WS protocol uses a simple request/response frame format:

type wsRequestFrame struct {
    Type   string         `json:"type"`   // Always "req"
    ID     string         `json:"id"`     // Client-generated correlation ID
    Method string         `json:"method"` // RPC method name
    Params map[string]any `json:"params,omitempty"`
}

type wsResponseFrame struct {
    Type    string         `json:"type"`    // Always "res"
    ID      string         `json:"id"`      // Echoes the request ID
    OK      bool           `json:"ok"`
    Payload map[string]any `json:"payload,omitempty"`
    Error   *wsErrorShape  `json:"error,omitempty"`
}

Connect Handshake

The first frame after the WebSocket upgrade must be a connect request. The adapter validates:

  • Role: must be "operator" or "node".
  • SPIFFE identity: must be present in the request context from the upstream mTLS handshake.
  • Device identity: required for the "node" role, including an auth.token field.
  • Scopes: parsed from the request and stored in the session for per-method authorization.

Role-Based Method Access

After connection, every method call is checked against the session's role and scopes via the wsAllowed function:

func wsAllowed(session wsSession, method string) bool {
    switch method {
    case "health":
        return true
    case "devices.list":
        if session.Role == "operator" { return true }
        _, ok := session.Scopes["devices:read"]
        return ok
    case "devices.ping":
        if session.Role == "operator" { return true }
        _, ok := session.Scopes["devices:write"]
        return ok
    case "message.send":
        if session.Role == "operator" { return true }
        _, ok := session.Scopes["tools.messaging.send"]
        return ok
    case "message.status":
        if session.Role == "operator" { return true }
        _, ok := session.Scopes["tools.messaging.status"]
        return ok
    case "connector.register":
        return session.Role == "operator"
    default:
        return false
    }
}
Context Lifetime

WebSocket connections outlive the original HTTP upgrade request. The reference adapter uses context.Background() for egress HTTP calls instead of req.Context(), because the upgrade request context may be canceled by the server while the WS connection is still active.

Per-Message Audit

Every WS frame, whether allowed or denied, is logged through the AuditLog facade method with correlation IDs, the session role, device ID, and the method name:

a.gw.AuditLog(middleware.AuditEvent{
    SessionID:  sessionID,
    DecisionID: decisionID,
    TraceID:    traceID,
    SPIFFEID:   middleware.GetSPIFFEID(req.Context()),
    Action:     "openclaw.ws." + method,
    Result:     result,
    Method:     http.MethodGet,
    Path:       openClawWSPath,
    StatusCode: statusCode,
})

Building a Webhook Receiver

Webhook receivers handle inbound platform callbacks (WhatsApp, Telegram, Slack) and route them through the gateway's ingress security stack. The reference adapter registers webhook routes under /openclaw/webhooks/.

Platform Extraction

The adapter determines the messaging platform from the URL path:

const (
    webhookBasePath     = "/openclaw/webhooks"
    whatsappWebhookPath = webhookBasePath + "/whatsapp"
    telegramWebhookPath = webhookBasePath + "/telegram"
    slackWebhookPath    = webhookBasePath + "/slack"
)

func platformFromPath(path string) string {
    switch path {
    case whatsappWebhookPath: return "whatsapp"
    case telegramWebhookPath: return "telegram"
    case slackWebhookPath:    return "slack"
    default:                  return ""
    }
}

Connector Signature Computation

Before processing any webhook payload, the adapter computes a deterministic connector signature and validates it against the Connector Conformance Authority (CCA):

func computeWebhookConnectorSig(connectorID, platform string) string {
    canon := map[string]any{
        "connector_id":   connectorID,
        "connector_type": "webhook",
        "platform":       platform,
    }
    data, _ := json.Marshal(canon)
    digest := sha256.Sum256(data)
    return hex.EncodeToString(digest[:])
}

// In the handler:
connectorID := extractConnectorID(payload, platform)
connectorSig := computeWebhookConnectorSig(connectorID, platform)
allowed, reason := a.gw.ValidateConnector(connectorID, connectorSig)
if !allowed {
    // Reject the webhook
}

Internal Loopback

After validation, the webhook receiver does not process the message directly. Instead, it POSTs a PlaneRequestV2 to the gateway's own /v1/ingress/submit endpoint via internal HTTP loopback. This ensures the inbound message passes through the full ingress middleware chain (DLP scanning, replay detection, freshness checks, etc.):

func (a *Adapter) postIngressLoopback(req gateway.PlaneRequestV2) (*http.Response, error) {
    body, err := json.Marshal(req)
    if err != nil {
        return nil, fmt.Errorf("marshal ingress request: %w", err)
    }

    loopbackURL := a.internalGatewayURL + "/v1/ingress/submit"
    httpReq, err := http.NewRequest(http.MethodPost, loopbackURL, bytes.NewReader(body))
    if err != nil {
        return nil, fmt.Errorf("build loopback request: %w", err)
    }
    httpReq.Header.Set("Content-Type", "application/json")
    // Carry the webhook actor identity for the ingress middleware chain.
    httpReq.Header.Set("X-SPIFFE-ID", req.Envelope.ActorSPIFFEID)

    client := &http.Client{Timeout: 10 * time.Second}
    return client.Do(httpReq)
}
Why Loopback?

The loopback pattern reuses the gateway's existing ingress middleware chain instead of duplicating it. This means the webhook gets the same DLP scanning, schema validation, replay detection, and audit logging that any other ingress message receives. The internal gateway URL is configurable via the GATEWAY_INTERNAL_URL environment variable (default: http://localhost:8443).

Late-Binding Secrets

WebSocket frames bypass the HTTP middleware chain, which means they do not automatically benefit from the SPIKE token substitution middleware (layer 13). Adapters must handle SPIKE token resolution explicitly for per-message secrets.

$SPIKE{...} References

When a WS frame includes an auth_ref parameter, the adapter checks whether the value is a SPIKE token reference. If so, it calls RedeemSPIKESecret to resolve the actual secret value at call time:

func (a *Adapter) resolveSPIKERef(ctx context.Context,
    authRef string, fallbackAuth string) (string, error) {

    if strings.TrimSpace(authRef) == "" {
        return fallbackAuth, nil
    }
    if strings.HasPrefix(authRef, "$SPIKE{") {
        resolved, err := a.gw.RedeemSPIKESecret(ctx, authRef)
        if err != nil {
            return "", fmt.Errorf("SPIKE token resolution failed: %w", err)
        }
        return "Bearer " + resolved, nil
    }
    // Non-SPIKE value: use as Bearer token directly.
    return "Bearer " + authRef, nil
}

Bearer Fallback Pattern

The resolution follows a three-tier fallback:

  1. If auth_ref is a $SPIKE{...} token, redeem it via SPIKE Nexus and use the resolved value as a Bearer token.
  2. If auth_ref is a plain string, use it directly as a Bearer token.
  3. If auth_ref is empty, fall back to the Authorization header from the original WebSocket upgrade request.

This pattern ensures that secrets are never stored in the adapter's memory longer than the duration of a single egress call. The SPIKE redeemer in the gateway contacts SPIKE Nexus over mTLS to resolve the token and returns only the secret value.

Testing Your Adapter

Walking Skeleton

Before building a full adapter, create a walking skeleton that validates your plane request shapes against the gateway's policy engine. The reference adapter includes a walking skeleton in POC/ports/openclaw/walking_skeleton/ that provides typed builders for each plane without any gateway dependency:

// walking_skeleton/adapter.go

type EnvelopeParams struct {
    RunID     string
    SessionID string
    SPIFFEID  string
    Plane     string
}

type ToolExecuteParams struct {
    EnvelopeParams
    Resource   string
    Attributes map[string]any
}

func BuildToolExecuteRequest(params ToolExecuteParams) map[string]any {
    resource := params.Resource
    if resource == "" {
        resource = "tool/read"
    }
    return buildRequest(
        params.EnvelopeParams,
        "tool.execute",
        resource,
        params.Attributes,
    )
}

The walking skeleton tests verify that request shapes are correct before you start wiring the adapter to real gateway services:

// walking_skeleton/adapter_test.go

func TestBuildToolExecuteRequestSupportsResourceOverride(t *testing.T) {
    req := BuildToolExecuteRequest(ToolExecuteParams{
        EnvelopeParams: EnvelopeParams{
            RunID:     "openclaw-it-tool-deny",
            SessionID: "openclaw-it-session-1",
            SPIFFEID:  "spiffe://poc.local/agents/mcp-client/openclaw/dev",
            Plane:     "tool",
        },
        Resource: "tool/write",
        Attributes: map[string]any{
            "capability_id": "tool.unapproved.mcp",
            "tool_name":     "write",
        },
    })

    policy := mapField(t, req["policy"])
    if policy["resource"] != "tool/write" {
        t.Fatalf("expected tool/write resource, got %v", policy["resource"])
    }
}

Unit Tests with Mock PortGatewayServices

For unit testing the adapter itself, create a mock that implements the PortGatewayServices interface. The interface has a deliberately narrow surface (12 methods), making mocks straightforward:

type mockGatewayServices struct {
    buildModelReq     func(r *http.Request, payload map[string]any) gateway.PlaneRequestV2
    evaluateModelDec  func(r *http.Request, req gateway.PlaneRequestV2) (
                          gateway.Decision, gateway.ReasonCode, int, map[string]any)
    executeModelEg    func(ctx context.Context, attrs map[string]any,
                          payload map[string]any, auth string) (*gateway.ModelEgressResult, error)
    evaluateToolReq   func(req gateway.PlaneRequestV2) gateway.ToolPlaneEvalResult
    // ... remaining methods
}

func (m *mockGatewayServices) BuildModelPlaneRequest(
    r *http.Request, payload map[string]any) gateway.PlaneRequestV2 {
    if m.buildModelReq != nil {
        return m.buildModelReq(r, payload)
    }
    return gateway.PlaneRequestV2{}
}
// ... implement remaining interface methods

Integration Tests with Real Stack

Integration tests should run against a real gateway instance with SPIRE, OPA, and SPIKE configured. Use docker compose or a Kubernetes cluster to stand up the full stack, then send requests to your adapter's routes:

# Start the full stack
make demo-compose

# Test the model plane
curl -s -X POST http://localhost:9090/v1/responses \
  -H "Content-Type: application/json" \
  -H "X-SPIFFE-ID: spiffe://poc.local/agents/mcp-client/openclaw/dev" \
  -d '{"model":"gpt-4o-mini","input":"Hello, world"}'

# Test the tool plane
curl -s -X POST http://localhost:9090/tools/invoke \
  -H "Content-Type: application/json" \
  -H "X-SPIFFE-ID: spiffe://poc.local/agents/mcp-client/openclaw/dev" \
  -d '{"tool":"tavily_search","args":{"query":"test"}}'

# Verify correlation headers in response
curl -s -D - -X POST http://localhost:9090/v1/responses \
  -H "Content-Type: application/json" \
  -H "X-SPIFFE-ID: spiffe://poc.local/agents/mcp-client/openclaw/dev" \
  -d '{"model":"gpt-4o-mini","input":"test"}' 2>&1 | grep X-Precinct

Registration

Registering an adapter is a single line in cmd/gateway/main.go. After creating the gateway instance and before starting the HTTP server, call RegisterPort:

// POC/cmd/gateway/main.go, line 96
gw.RegisterPort(openclaw.NewAdapter(gw))

The RegisterPort method appends the adapter to the gateway's dispatch chain:

// POC/internal/gateway/port_services.go
func (g *Gateway) RegisterPort(adapter PortAdapter) {
    g.portAdapters = append(g.portAdapters, adapter)
}

Multiple adapters can be registered. The gateway tries them in registration order; the first one whose TryServeHTTP returns true wins the request.

Adapter Constructor Pattern

Adapters receive the gateway as a PortGatewayServices interface, not a concrete *Gateway pointer. This keeps the coupling narrow and testable:

type Adapter struct {
    gw                 gateway.PortGatewayServices
    internalGatewayURL string
}

func NewAdapter(gw gateway.PortGatewayServices) *Adapter {
    gwURL := os.Getenv("GATEWAY_INTERNAL_URL")
    if gwURL == "" {
        gwURL = "http://localhost:8443"
    }
    return &Adapter{gw: gw, internalGatewayURL: gwURL}
}

// For testing with an explicit loopback URL:
func NewAdapterWithLoopbackURL(
    gw gateway.PortGatewayServices, loopbackURL string) *Adapter {
    return &Adapter{gw: gw, internalGatewayURL: loopbackURL}
}

Checklist

Use this checklist to ensure your adapter is complete and ready to ship.

Adapter implementation checklist
Item Description File
Interface compliance Struct implements gateway.PortAdapter with compile-time check adapter.go
Name() unique Returns a short, unique identifier not used by other adapters adapter.go
TryServeHTTP dispatch Path matching returns false for unrecognized paths; never writes to w when returning false adapter.go
Protocol types separated Wire-format types and parsing in a protocol/ subpackage protocol/
Model plane handler Calls BuildModelPlaneRequest, EvaluateModelPlaneDecision, ExecuteModelEgress, LogPlaneDecision http_handler.go
Tool plane handler Evaluates tool policy, handles step-up approval, logs decisions http_handler.go
Correlation headers Every response sets X-Precinct-Decision-ID, X-Precinct-Trace-ID, X-Precinct-Reason-Code http_handler.go
Error responses Uses WriteGatewayError for framework errors; includes correlation headers in custom error responses http_handler.go
WS handler (if needed) Connect handshake, role/scope validation, per-message audit, context.Background() for egress ws_handler.go
Webhook receiver (if needed) Connector signature validation, internal loopback to /v1/ingress/submit webhook_receiver.go
SPIKE token resolution $SPIKE{...} references resolved via RedeemSPIKESecret with Bearer fallback messaging_egress.go
Walking skeleton tests Request-shape builders with unit tests, no gateway dependency walking_skeleton/
Unit tests Mock PortGatewayServices, test all handler paths (allow, deny, error) *_test.go
Integration tests Real gateway + SPIRE + OPA stack, test full request lifecycle e2e/
Registration One-line gw.RegisterPort(yourpkg.NewAdapter(gw)) in cmd/gateway/main.go main.go