Problem
The capability binding matrix is narrow enough that the core Code Mode value proposition — a model writing a Starlark program that loops, filters, and aggregates over intermediate results in-runtime instead of round-tripping bulk data through the context window — is not expressible.
Current matrix (internal/binding/plan.go):
| Direction |
Allowed |
| Input |
required string, optional *int64 — nothing else |
| Output |
flat struct of string / int64 / bool / float64 — no slices, no nesting, no optional fields |
Concrete consequences:
- A capability cannot return a list. "Search returns N results" is unrepresentable, so the canonical loop — fetch a collection, filter in Starlark, call a capability per surviving item, return a digest — cannot be written. Starlark's comprehensions and builtins have nothing to operate on.
- A capability cannot take a required int, a bool, a float, or an optional string.
page: int or dry_run: bool hit the wall immediately.
- With composition unavailable, the model's best move is one native call wrapped in a mandatory
def main(): — strictly worse ergonomics than a plain MCP tool for the same job. The program-authoring tax has no composition payoff.
The irony: the machinery to do this generically already ships. binding.ConvertFinal (internal/binding/output.go) is a generic Starlark→JSON walker supporting dicts, lists, tuples, and scalars, with depth limits, byte budgets, cycle rejection, int64 range checks, and NaN/Inf rejection. Only capability I/O (Plan.ConvertOutput and the fieldKind whitelist) is scalar-locked.
Proposal
Keep the registration API exactly as-is. Hosts still declare codemode.Capability[Input, Output] with plain Go structs. The change is what those types may contain.
Output side (the big unlock)
Accept any JSON-marshalable Go shape: slices, nested structs, pointer/optional fields, maps with string keys. Convert handler output to Starlark values (dicts/lists/scalars) with a generic reflection walker modeled on ConvertFinal's existing design, including its finiteness and range checks. Delete the output half of the fieldKind whitelist.
This should be a net deletion of complexity: the fieldKind enum and the per-kind switches in plan.go, signature.go, and output.go collapse into one walker.
Input side (widen, do not loosen)
Inputs stay strictly typed and validated before authorization, because binding produces the canonical argument map the authz.Authorizer evaluates. Widen the accepted kinds: required int64 / bool / float64, optional *string / *bool / *float64. Composite inputs (lists, nested objects) can follow as a second phase once output-side behavior is observed — they raise canonicalization questions that scalars do not.
Discovery stays derived, never declared
describe_api shapes must continue to be generated at registration from the reflected Output type so the schema the model sees cannot drift from handler behavior. binding.FieldShape will need to represent nesting and arrays (recursive shape or a JSON-Schema-like notation). No hand-written schemas.
Invariants that must not change
- bind → canonicalize → authorize → dispatch ordering (
internal/execution/execute.go)
- The authorizer receives canonical arguments derived from validated typed input
MaxValueDepth / MaxResultBytes enforcement on values crossing the boundary
- Registration-time rejection of genuinely unsupportable shapes (channels, funcs, interfaces, non-string map keys, cyclic types), with the existing field-level host-side error messages
- Deterministic signatures and name-sorted discovery
Non-goals
- Handlers returning JSON text (
json.RawMessage / any passthrough): forfeits the derived describe_api contract and either strands the model with unparseable strings (no json module is predeclared) or reintroduces the same conversion one layer down.
- Exposing
starlarkjson or other stdlib modules to programs.
- Changing the three-tool MCP surface.
Acceptance sketch
- A capability declared with
Output containing []struct{...} registers, and a Starlark program can for r in resp["items"]: over the result.
describe_api for that capability shows the nested/array shape, derived from the Go type.
- A handler returning NaN, an over-range integer, or an unsupported kind still fails safely (
capability failed), and unsupported types still fail at registration, not at call time.
docs/docs/reference/mcp-tools.md and docs/docs/reference/public-api.md updated to the widened matrix.
Origin
Product/UX review of the current surface at 8b5302b. Related but independent: the error-diagnostics issue (filed separately) — both target the model-side authoring loop.
Problem
The capability binding matrix is narrow enough that the core Code Mode value proposition — a model writing a Starlark program that loops, filters, and aggregates over intermediate results in-runtime instead of round-tripping bulk data through the context window — is not expressible.
Current matrix (
internal/binding/plan.go):string, optional*int64— nothing elsestring/int64/bool/float64— no slices, no nesting, no optional fieldsConcrete consequences:
page: intordry_run: boolhit the wall immediately.def main():— strictly worse ergonomics than a plain MCP tool for the same job. The program-authoring tax has no composition payoff.The irony: the machinery to do this generically already ships.
binding.ConvertFinal(internal/binding/output.go) is a generic Starlark→JSON walker supporting dicts, lists, tuples, and scalars, with depth limits, byte budgets, cycle rejection, int64 range checks, and NaN/Inf rejection. Only capability I/O (Plan.ConvertOutputand thefieldKindwhitelist) is scalar-locked.Proposal
Keep the registration API exactly as-is. Hosts still declare
codemode.Capability[Input, Output]with plain Go structs. The change is what those types may contain.Output side (the big unlock)
Accept any JSON-marshalable Go shape: slices, nested structs, pointer/optional fields, maps with string keys. Convert handler output to Starlark values (dicts/lists/scalars) with a generic reflection walker modeled on
ConvertFinal's existing design, including its finiteness and range checks. Delete the output half of thefieldKindwhitelist.This should be a net deletion of complexity: the
fieldKindenum and the per-kind switches inplan.go,signature.go, andoutput.gocollapse into one walker.Input side (widen, do not loosen)
Inputs stay strictly typed and validated before authorization, because binding produces the canonical argument map the
authz.Authorizerevaluates. Widen the accepted kinds: requiredint64/bool/float64, optional*string/*bool/*float64. Composite inputs (lists, nested objects) can follow as a second phase once output-side behavior is observed — they raise canonicalization questions that scalars do not.Discovery stays derived, never declared
describe_apishapes must continue to be generated at registration from the reflectedOutputtype so the schema the model sees cannot drift from handler behavior.binding.FieldShapewill need to represent nesting and arrays (recursive shape or a JSON-Schema-like notation). No hand-written schemas.Invariants that must not change
internal/execution/execute.go)MaxValueDepth/MaxResultBytesenforcement on values crossing the boundaryNon-goals
json.RawMessage/anypassthrough): forfeits the deriveddescribe_apicontract and either strands the model with unparseable strings (nojsonmodule is predeclared) or reintroduces the same conversion one layer down.starlarkjsonor other stdlib modules to programs.Acceptance sketch
Outputcontaining[]struct{...}registers, and a Starlark program canfor r in resp["items"]:over the result.describe_apifor that capability shows the nested/array shape, derived from the Go type.capability failed), and unsupported types still fail at registration, not at call time.docs/docs/reference/mcp-tools.mdanddocs/docs/reference/public-api.mdupdated to the widened matrix.Origin
Product/UX review of the current surface at 8b5302b. Related but independent: the error-diagnostics issue (filed separately) — both target the model-side authoring loop.