Write a connector¶
A connector is how a run reaches a company system, and the gateway is the only place credentials live. You have three options, in order of effort.
Option 1: configure a built-in connector¶
The gateway ships with sqlite (named statements), http (host-allow-listed
requests), fs (files under a root) and mcp (any stdio MCP server). Most
systems with an HTTP API or a database need no code:
{
"name": "crm",
"type": "http",
"allowed_hosts": ["crm.internal.example"],
"tools": {
"get": {"writes": false, "contract": {"required": {"status": "number", "body": "string"}}},
"post": {"writes": true, "contract": {"required": {"status": "number"}}}
},
"probe": {"tool": "get", "args": {"url": "https://crm.internal.example/health",
"headers": {"Authorization": "Bearer ${CRM_TOKEN}"}}}
}
${CRM_TOKEN} is substituted from the gateway's environment at load time and
never appears in a log, an event or a response. The scope of every call is
derived from the URL's host, so a remit lists http:host:crm.internal.example.
Option 2: point at an MCP server¶
{"name": "docs", "type": "mcp", "command": ["npx", "-y", "some-mcp-server"],
"env": {"DOCS_TOKEN": "${DOCS_TOKEN}"}}
The gateway starts the server, lists its tools as docs.<tool>, takes writes
from the server's tool annotations, and requires the literal scope mcp:docs:*
in the remit unless the server declares a scope of its own.
Option 3: write one in Go¶
For a system with its own protocol, or where scope must be derived from the
arguments in a way the built-ins cannot, implement the connect.Connector
interface from github.com/rhs2/kernos/gateway/connect:
package ledger
import (
"context"
"github.com/rhs2/kernos/gateway/connect"
)
type Ledger struct{ client *api.Client }
func New(cfg map[string]any) (connect.Connector, error) {
return &Ledger{client: api.Dial(cfg["endpoint"].(string), cfg["token"].(string))}, nil
}
func (l *Ledger) Name() string { return "ledger" }
func (l *Ledger) Tools() []connect.ToolSpec {
return []connect.ToolSpec{{
ID: "ledger.post_entry", Description: "Post a journal entry", Writes: true,
InputSchema: map[string]any{"type": "object", "required": []any{"period", "lines"}},
Contract: connect.Contract{Required: map[string]string{"entry_id": "string"}},
ScopeDerivation: "period",
}}
}
func (l *Ledger) Call(ctx context.Context, tool string, args map[string]any) (map[string]any, []string, error) {
period, _ := args["period"].(string)
scopes := []string{"ledger:period:" + period}
entry, err := l.client.Post(ctx, args)
if err != nil {
if api.IsValidation(err) {
return nil, scopes, fmt.Errorf("%w: %v", connect.ErrDeterministic, err) // do not retry
}
return nil, scopes, err // retried by the kernel's policy
}
return map[string]any{"entry_id": entry.ID}, scopes, nil
}
func (l *Ledger) Probe(ctx context.Context) (map[string]any, error) {
return l.client.Health(ctx) // harmless, checked against the contract every interval
}
func init() { connect.Register(New, "ledger") }
Three rules make a connector trustworthy:
- Derive scope from the arguments, never from a field the caller supplies. The model cannot claim a scope it does not have if the connector computes it.
- Wrap deterministic failures with
connect.ErrDeterministicso the kernel quarantines instead of retrying; leave transient errors unwrapped so they are retried with backoff. - Declare a contract and a probe. The canary loop is what turns an upstream schema change into a quarantine and a repair request instead of a month of wrong output.
Register the package in your gateway binary (import it for its init), rebuild,
and reference it by type in gateway.json. The full interface is in the
connector SDK reference.