Overview & the port model
Mecatl uses ports and adapters to keep the agent loop independent of providers, storage, policy engines, and operating-system integrations. You can replace one of these capabilities without changing the loop or the other adapters.
The dependency flow
engine/agent depends on interfaces in engine/port, the domain packages, and
the standard library. Construct the adapters in your composition root and inject
them into the loop.
The port interfaces
Most seams the loop can be extended through are defined in engine/port. The
table also includes service-owned persistence seams such as EventLog, which
the relay uses but the agent loop does not consume directly.
| Interface | File | Abstracts | Reference adapters |
|---|---|---|---|
LLMProvider | port/llm.go | Model calls — streams Chunk values; reports multimodal ProviderCapabilities | engine/adapter/mockllm (offline); provider/openai, provider/anthropic, provider/openaichat (opt-in submodules); internal/adapter/openrouter; internal/adapter/llmresilience (decorator) |
SessionStore | port/store.go | Persist and reload session state | engine/adapter/memstore (in-memory, tests); internal/adapter/store/jsonlstore (append-only JSONL); internal/adapter/redisstore (Redis-backed) |
PrunableStore | port/store.go | Optional retention sweep (list + delete sessions) | Same implementations that also carry SessionStore; discovered by type assertion |
PermissionPolicy | port/permission.go | Evaluate a tool call → allow / ask / deny; learn per-session allow rules | engine/adapter/permpolicy (wraps the session-free governance.Evaluator) |
PermissionStore | port/permission.go | Hold per-session learned rules | engine/adapter/permstore |
HookRunner | port/hookrunner.go | Execute lifecycle hooks (PreToolUse, PostToolUse, etc.) | internal/adapter/hookexec (shell-exec); engine/adapter/mockllm test stubs |
EventLog | port/eventlog.go | Durable append-only per-session event record; owned by the service relay rather than consumed by the agent loop | engine/adapter/memstore (in-memory); internal/adapter/store/jsonlstore (.events.jsonl sidecar); internal/adapter/redisstore |
EventSink | port/log.go | Live mirror of the event stream (telemetry, ACP relay) | internal/adapter/server (gRPC/HTTP relay); internal/adapter/telemetry |
ToolCallRecorder | port/log.go | Per-tool audit record (timing, call, result) | internal/adapter/store/jsonlstore; internal/adapter/redisstore; internal/adapter/telemetry |
Diagnostics | port/diagnostics.go | Operator-facing log lines (structured key/value, slog-shaped) | internal/adapter/slogdiag (the only slog bridge); port.NopDiagnostics (zero-value default) |
Clock | port/clock.go | Wall clock — Now() time.Time | engine/adapter/wallclock (production); test fakes inline in engine tests |
SessionLease | port/lease.go | Cross-process single-writer lease for a session id (optional; cloud-native Phase 4) | engine/adapter/memlease; internal/adapter/flocklease; internal/adapter/k8slease; internal/adapter/grpcdriver |
Ports outside engine/port
The filesystem and execution-environment ports live in engine/tool:
tool.FileSystemprovides the underlying filesystem operations.tool.Workspaceadds version-aware reads, create-only writes, conditional replacement, and a per-environment read ledger. Implementations mint opaqueFileVersionvalues. The public interface has no unconditional overwrite.tool.Environmentcombines a durablesession.EnvironmentRef, a non-nilWorkspace, and an optionalCommandRunner. A runner is bound to one namespace when constructed; if it is absent, Shell returnsErrNoShell.tool.WorkspaceNamespaceoptionally adds immediate directory listing, non-recursive removal, no-clobber rename, and no-clobber regular-file copy. Built-in namespace tools report unsupported when an embedder omits it.tool.EnvironmentForkerandtool.EnvironmentMergercreate and merge child environments for isolated work.
Use engine/adapter/memfs for tests or internal/adapter/osfs for an OS-backed
workspace. The ACP integration supplies an editor-buffer workspace. For the
version protocol and environment lifecycle, see
ADR 0208,
ADR 0211,
and
ADR 0214.
When to implement a port vs. use the reference adapters
Most deployments use the reference adapters directly. Implement a port only when your application needs a different capability at that boundary.
Implement a port when you need to swap a specific capability at the boundary:
| Scenario | Port to implement |
|---|---|
| Route to a different LLM provider (your own inference cluster, proxy, or custom API) | LLMProvider |
| Store sessions in your own database (PostgreSQL, DynamoDB, …) | SessionStore (+ optionally PrunableStore) |
| Enforce your own permission logic (RBAC, OPA, org-level policy engine) | PermissionPolicy |
| Audit tool calls into your own observability pipeline | ToolCallRecorder |
| Route operator log lines to your logging infrastructure | Diagnostics |
| Implement session leasing against your own distributed lock service | SessionLease |
You do not need to implement a port to:
- Change which model is used — pass the model name through
internal/app's provider registry. - Change permission rules — write
settings.yamlconfig. The existingPermissionPolicyadapter picks it up per session. - Add lifecycle hooks — write shell hooks or use
internal/adapter/hookexec. TheHookRunnerport is for replacing the execution engine, not adding hooks. - Add tools — extend the
tool.Catalogat composition time.
How adapters are wired: the composition pattern
Mecatl uses explicit constructors. The shipped commands share the composition
root in internal/app/build.go; an embedding application can follow the same
pattern in its own composition root.
The schematic below shows how ports are satisfied for a typical deployment.
Actual field names are illustrative; see internal/app/build.go for the live
signatures.
// internal/app/build.go (schematic)
func Build(cfg Config) (*server.Service, error) {
// 1. Stand up the store (satisfies SessionStore, PrunableStore, EventLog, ToolCallRecorder)
store, err := jsonlstore.New(cfg.DataDir)
// 2. Build the LLM provider (satisfies LLMProvider)
// The llmresilience decorator wraps the raw provider with retry + stream watchdog.
raw, err := openai.New(openai.WithAPIKey(cfg.OpenAIKey), openai.WithBaseURL(cfg.BaseURL))
provider := llmresilience.Wrap(raw, llmresilience.Config{StreamIdleTimeout: 180 * time.Second})
// 3. Permission policy + store (satisfies PermissionPolicy, PermissionStore)
permStore := permstore.New()
rules := []governance.Rule{ /* your rules */ }
policy := permpolicy.NewPolicy(rules, permStore)
// 4. Diagnostics (satisfies Diagnostics)
diag := slogdiag.NewFromLogger(slog.Default())
// 5. Clock (satisfies Clock) -- the zero value is ready to use, no constructor
clk := wallclock.Clock{}
// 6. Hook runner (satisfies HookRunner)
hooks, err := hookexec.New(cfg.HookConfig)
// 7. Inject into the engine
// Note: EventLog is NOT an engine.Deps field — the service layer (not the engine)
// appends to the log. Pass store to the service constructor instead.
eng := agent.NewEngine(agent.Deps{
LLM: provider,
Store: store,
Policy: policy,
Hooks: hooks,
ToolCallRecorder: store,
Diagnostics: diag,
Clock: clk,
})
return server.New(eng, store, ...), nil
}
The example demonstrates three composition rules:
- One object can satisfy multiple ports.
jsonlstore.StoreimplementsSessionStore,PrunableStore,EventLog, andToolCallRecorder. Pass it separately wherever each interface is required; the service, rather than the engine, owns theEventLog. - Adapters are never imported by the engine.
agent.Depscarries interface values only. A new LLM adapter never requires an engine change. - Composition is the only place adapters meet. Domain packages and
engine/agenthave no adapter imports, which the depguard allowlist and the DAG test verify on every build.
Replacing a single adapter
To swap, say, SessionStore for your own database backend:
- Implement
port.SessionStore(and optionallyport.PrunableStore) in a new package. - In your composition root (either your own
mainor a fork ofinternal/app/build.go), construct your store and pass it in place ofjsonlstore.New(...). - Leave the engine and unrelated adapters unchanged.
To validate your implementation against the conformance suite:
// Run the standard store conformance tests against your adapter.
storeconformance.Run(t, func(t *testing.T) port.SessionStore { return yourstore.New() })
Conformance suites ship in engine/adapter/storeconformance,
leaseconformance, fsconformance, sourceconformance, memconformance,
eventlogconformance, and scheduleconformance. An adapter that passes its
suite is compatible with Mecatl's expectations.
What's next
- LLM provider — implement
port.LLMProviderto route to a custom model endpoint. - Session store — implement
port.SessionStore(and the optionalPrunableStore/EventLogseams) for your own persistence backend. - Permission policy — replace Layer 1's rule engine with your own authorization logic.
- Session lease — implement
port.SessionLeasefor cross-process single-writer session exclusion.