mcp: ONE door — three hand-rolled registries collapse into the typed-op projection

Typing a route bought OpenAPI prose, an SDK method and a CLI command, and NOTHING
on the public MCP surface. zip has projected every typed op into an MCP tool since
v1.18.6 and cloud never called it: manifest routed /v1/mcp to apps/tools, which
hand-rolled its own tools/list + tools/call over a route-table scrape, and
apps/automations hand-rolled a THIRD catalogue. Three registries for one concept,
and the one the public reached exposed none of the 549 typed ops.

THE DOOR IS THE HOST'S. cmd/cloud sets zip.MCPConfig{Path:"/v1/mcp"} and hands
each plugin its own catalogue at Load. The host is the only process that CAN own
it: MCPTools() is in-process, so a plugin cannot enumerate a lazy sibling, and a
plugin-hosted door costs its own wake on the first list. Measured: POST /v1/mcp
beats ai's "/v1" remainder by specificity, not registration order.

THE LIST IS A BUILD ARTIFACT, so tools/list costs ZERO wakes. It has to be: 112
plugins mount LAZILY, and an MCP client calls tools/list constantly — a door that
fanned out over ZAP to ask would destroy the one invariant that makes 112 services
affordable. The answer is already fixed at build time, by the same typed-op
registry that emits openapi.json, so `<app> describe <dir>` now writes BOTH
projections from ONE mount at ONE instant: openapi.json and mcp.json. They cannot
be generated apart, so a tool cannot exist without its op or carry a stale schema.
The leaf plugin/embed.go go:embeds them (cmd/cloud goes 344 → 345 packages, still
zero from apps/). Measured live with the WHOLE fleet mounted: 549 tools listed,
child count 4 → 4 (the four eager apps, untouched).

tools/call is the ONLY trigger and starts exactly one child — p.target(), the same
single-flighted lazy path a prefix request takes — then forwards the SAME message
to that plugin's own /mcp over ZAP on its 0700 unix socket. Never HTTP. The child's
registry answers, so the host can only NAME a tool, never invoke one the child did
not declare. Measured live: get_v1_pricing woke 1 child and returned the pricing
catalog; get_v1_company answered its own handler's "X-Org-Id required" through the
plugin's full cloud.Serve identity chain.

DELETED, not left dark:
  apps/tools/builtin.go (223 lines) — the "full-cloud-control" route→tool scrape.
    Structurally dead since the monolith died: in the tools CHILD, GetRoutes() sees
    only tools' own ~13 routes, and its schemas were opaque {query,body} objects a
    model cannot fill. The new door is what it meant to be, with real schemas.
  apps/tools/http.go's mcp/mcpToolList/mcpToolCall/rpcResult/rpcError + the route.
  apps/automations/mcp.go's mcp/mcpTools/mcpResultObj/mcpErrorObj + its route.
  GET /v1/mcp — a Source view that is GET /v1/tools?source=mcp by its own comment.
  Principal.credential + credentialHeaders — replay state only builtin.go read.

KEPT, because it is a different capability: apps/tools' EXTERNAL MCP server
registry (records, KMS-sealed secrets, SSRF-validated dialer, tools/list fan-out),
now owning /v1/mcp/servers alone. Its tools, org skills, agents, functions and
connector actions are ROWS, not code, so no build-time catalogue can hold them —
they are reached through the typed POST /v1/tools/call, which is itself a tool on
the door. Nothing lost: connectorToolProvider already published every connector
action into that one registry.

THE GATE. mk/fleet.mk surface-check (which .hanzo/workflows/cicd.yml → hanzo.yml
app-contract actually invokes) regenerates every app FROM SOURCE and fails on
`git status --porcelain -- openapi.yaml plugin/` — mcp.json is under plugin/, so it
was covered the moment it landed there. PROVEN TO FIRE: adding one typed op to
apps/guide without regenerating turned it red on BOTH plugin/guide/mcp.json and
plugin/guide/openapi.json; reverted, green. Four more, all cheap: no App row may
claim /v1/mcp (fiber MERGES byte-identical patterns, so a Load there would shadow
the door silently); no served path may END in /mcp; no Go source outside cmd/cloud
may name an /mcp path unless it is a named foreign engine (apps/tasks' own
surface, which is not a projection of our ops); every catalogue tool must be an
operationId of its own app, unique fleet-wide, with a NON-EMPTY description —
the last one because a nameless tool is a silent failure a model pays context for.

549 tools across 36 apps, 349KB on the wire. zip v1.18.11 → v1.18.12.

Capability check, precisely: the 17 executable connector actions the deleted
automations door listed are NOT tool names on the fleet door, because they are
per-tenant rows — connectorToolProvider publishes every one of them into the ONE
registry from the same `registry` map that door read, so they are reached through
tools_call with the same activation, price, meter and audit. Nothing is lost; one
hop is added. Same for org skills, agents, functions and external MCP servers.

One door this gate structurally cannot claim: /v1/tasks/mcp is hanzoai/tasks' own
engine surface behind cloud's identity gate, mounted on a raw net/http mux so it
is in no subset at all. It is a foreign engine's tools, not a projection of ours,
so it is NAMED in foreignDoors with the reason rather than deleted.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
This commit is contained in:
2026-07-30 10:11:38 -07:00
parent ea4be9109d
commit e247e255cf
161 changed files with 14365 additions and 1398 deletions
+30 -15
View File
File diff suppressed because one or more lines are too long
+7 -7
View File
@@ -53,7 +53,7 @@ APPS := $(shell sed -n 's/.*{Name: "\([^"]*\)".*/\1/p' manifest/apps.go)
# them in parallel and build exactly the one you ask for.
APP_BINS := $(addprefix bin/,$(APPS))
.PHONY: help webui deploy-ui agentskills build cloud ship apps $(APP_BINS) plugin generate openapi run smoke test test-fast test-cgo test-codec vet tidy docker docker-push clean e2e
.PHONY: help webui deploy-ui agentskills build cloud ship apps $(APP_BINS) plugin generate describe run smoke test test-fast test-cgo test-codec vet tidy docker docker-push clean e2e
help: ## Show this help.
@awk 'BEGIN{FS=":.*##";printf "\nUsage: make <target>\n\nTargets:\n"} /^[a-zA-Z_-]+:.*##/{printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
@@ -219,7 +219,7 @@ test: ## Run unit + integration tests (pure-Go, with the FTS5 tag the image ship
# The drift gate: regenerate the document FROM SOURCE and fail on any diff.
# The weave above proves the subsets compose; this proves they are still the
# routes. Only the second one catches a route added without regenerating.
$(MAKE) -f mk/fleet.mk openapi-check
$(MAKE) -f mk/fleet.mk surface-check
# The inner loop. Everything `test` runs EXCEPT the drift gate, which rebuilds one
# binary per app and dominates the wall clock.
@@ -231,7 +231,7 @@ test: ## Run unit + integration tests (pure-Go, with the FTS5 tag the image ship
test-fast: ## Everything `test` runs except the spec drift gate. Inner loop only — CI runs `test`.
@echo ">> test-fast: NOT checking spec drift (openapi.yaml + plugin/*/openapi.json)."
@echo ">> a route added without regenerating will pass here and fail CI."
@echo ">> the real gate: make -f mk/fleet.mk openapi-check"
@echo ">> the real gate: make -f mk/fleet.mk surface-check"
@set -e; for d in $$(grep -rl '^//go:generate go run github.com/zap-proto/zip/cmd/zipdoc' --include='*.go' clients cmd . 2>/dev/null | xargs -n1 dirname | sort -u); do \
(cd $$d && $(GO) run github.com/zap-proto/zip/cmd/zipdoc -check) || { echo "$$d/zipdoc_gen.go is stale — run: go generate -run zipdoc ./$$d/..."; exit 1; }; \
done
@@ -264,15 +264,15 @@ test-fast: ## Everything `test` runs except the spec drift gate. Inner loop only
# same stale subset, `make test` stayed green, and the entire ingress API was
# missing from the spec every SDK is generated from.
#
# The DRIFT GATE (openapi-check) is the one that catches that: it REGENERATES
# The DRIFT GATE (surface-check) is the one that catches that: it REGENERATES
# from source and fails on any diff. It is the expensive half — one binary per
# app — and it is in `make test` anyway, because the cheap half is exactly the
# check that passed while the published document was missing an entire API.
openapi: ## Regenerate every app subset, then weave them into openapi.yaml.
describe: ## Regenerate every app's projections, then weave them into openapi.yaml.
$(GO) generate -run zipdoc ./...
$(MAKE) -f mk/fleet.mk openapi-apps
$(MAKE) -f mk/fleet.mk describe-apps
$(MAKE) -f mk/fleet.mk openapi-weave OUT=openapi.yaml
@echo ">> openapi.yaml — $$(grep -c '^ /' openapi.yaml) paths"
@echo ">> openapi.yaml — $$(grep -c '^ /' openapi.yaml) paths, $$(cat plugin/*/mcp.json | grep -c '\"name\":') MCP tools"
test-cgo: ## Prove the cgo build works too — forces the fork's pure-Go backend via -tags sqlite_purego so the embedded modernc importers don't double-register "sqlite".
$(TEST_ENV) CGO_ENABLED=1 $(GO) test -tags "sqlite_purego $(TEST_TAGS)" ./...
+3 -8
View File
@@ -35,7 +35,6 @@
// ✓ GET /v1/automations/runs/:id run detail (refreshed from engine)
// POST /v1/automations/runs/:id/resume resume a paused run — arbitrary JSON in
// POST /v1/automations/hooks/:source/:event inbound event sink — raw-byte dedupe
// POST /v1/automations/mcp MCP JSON-RPC — 200 on an unparseable body
package automations
import (
@@ -155,8 +154,9 @@ func Mount(app cloud.Router, deps cloud.Deps) error {
routes(app, s)
// Register every connector action into the unified tool plane. The /v1/automations/mcp
// endpoint stays (connector-scoped MCP view); the plane surfaces the SAME tools org-wide.
// Register every connector action into the unified tool plane. This is the ONLY
// projection of them: discovery is GET /v1/tools, dispatch is POST /v1/tools/call,
// and through that registry every action is a tool on the fleet's one agent door.
tools.Register(connectorToolProvider{})
b.Log.Info("automations mounted", "connectors", catalog.ConnectorCount, "runtime", len(registry), "brand", deps.Brand)
@@ -261,11 +261,6 @@ func routes(app cloud.Router, s *cloud.Service[state]) {
// see the resume note above — it costs the MCP and call-plane projections, which is
// what typing is FOR. See inboundHook.
g.Post("/hooks/:source/:event", cloud.Handle(s, inboundHook))
// UNTYPED — JSON-RPC answers a body it cannot parse with HTTP 200 and a -32700
// error object; zip unmarshals BEFORE the handler, so typing it would turn that
// 200 into a 400. See mcp.
g.Post("/mcp", cloud.Handle(s, mcp))
}
// Shutdown closes the store. Idempotent — safe when nothing is mounted.
+1 -1
View File
@@ -10,7 +10,7 @@ import (
// connector_content.go registers the "content" connector: the marketing content loop
// (clients/content) exposed as automation ACTIONS and MCP TOOLS, so the loop runs
// AUTONOMOUSLY. Each action is both a flow step and an MCP tool named
// "content_<action>" at POST /v1/automations/mcp — the surface a scheduled flow, an
// "content_<action>" on the unified tool plane — the surface a scheduled flow, an
// /v1/agents tool call, or a headless hanzo-bot drives.
//
// Why a first-class connector and not core.http_request → /v1/content/*: core.http_request
+7 -9
View File
@@ -230,18 +230,16 @@ func TestConcurrencyLimiter(t *testing.T) {
}
}
// ── LOW-1: MCP outcome is derived from the real result, after Run ───────────
// ── LOW-1: a tool-call outcome is derived from the real result, after Run ───────
func TestMCPAuditOutcome(t *testing.T) {
app, rec := newAppWithAudit(t)
func TestToolCallAuditOutcome(t *testing.T) {
_, rec := newAppWithAudit(t)
// Success: core_code runs and returns.
reqRaw(t, app, "/v1/automations/mcp", "acme",
`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"core_code","arguments":{"k":"v"}}}`)
_, _ = InvokeTool(context.Background(), "acme", "core_code", map[string]any{"k": "v"})
// Failure: slack is not connected → Run errors.
reqRaw(t, app, "/v1/automations/mcp", "acme",
`{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"slack_send_message","arguments":{"channel":"C","text":"hi"}}}`)
_, _ = InvokeTool(context.Background(), "acme", "slack_send_message", map[string]any{"channel": "C", "text": "hi"})
rows, _, err := rec.Query(context.Background(), audit.Filter{Org: "acme", Action: "automations.mcp.call", Limit: 100})
rows, _, err := rec.Query(context.Background(), audit.Filter{Org: "acme", Action: "automations.tool.call", Limit: 100})
if err != nil {
t.Fatalf("audit query: %v", err)
}
@@ -255,7 +253,7 @@ func TestMCPAuditOutcome(t *testing.T) {
}
}
if ok != 1 || bad != 1 {
t.Fatalf("want 1 ok + 1 error mcp.call audit (outcome from real result), got ok=%d error=%d (total %d)", ok, bad, len(rows))
t.Fatalf("want 1 ok + 1 error tool.call audit (outcome from real result), got ok=%d error=%d (total %d)", ok, bad, len(rows))
}
}
+4 -3
View File
@@ -79,9 +79,10 @@ func toolsCall(t *testing.T, app *zip.App, org, op string, args string) (string,
}
// derivedTools returns the names of the tools ZIP derives from this package's
// typed-op registry — one per typed op, none for an untyped route. Distinct from
// mcp.go's mcpTools, which is the connector-scoped list automations serves itself
// at /v1/automations/mcp.
// typed-op registry — one per typed op, none for an untyped route. This is the
// catalogue `automations describe` serialises and the host composes onto the
// fleet's one MCP door; a connector ACTION is a different value, published into
// the unified tool plane by connectorToolProvider.
func derivedTools(app *zip.App) []string {
var names []string
for _, tool := range app.MCPTools() {
-1
View File
@@ -21,7 +21,6 @@ func TestOrgGating403(t *testing.T) {
{http.MethodGet, "/v1/automations/flows/x"},
{http.MethodGet, "/v1/automations/runs"},
{http.MethodPost, "/v1/automations/flows/x/run"},
{http.MethodPost, "/v1/automations/mcp"},
}
for _, g := range gated {
if r := req(t, app, g.method, g.path, "", nil); r.Code != http.StatusForbidden {
+17 -50
View File
@@ -2,29 +2,30 @@ package automations
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/audit"
"github.com/zap-proto/zip"
)
// invoke.go decomplects tool dispatch from its front doors. ONE core (dispatchTool)
// invoke.go decomplects tool dispatch from its callers. ONE core (dispatchTool)
// resolves a tool name to its connector action and runs it with a RunContext whose
// credential Token is pinned to the VALIDATED org; TWO doors call it:
// credential Token is pinned to the VALIDATED org.
//
// - mcpToolCall — the HTTP JSON-RPC door (POST /v1/automations/mcp tools/call),
// metering + auditing with the request context.
// - InvokeTool — the in-process door a sibling subsystem (the Business AI guide)
// uses to act through the per-principal MCP plane without an HTTP hop, metering
// + auditing with no HTTP context.
// Two callers reach it, and neither is an HTTP door of this subsystem's own:
//
// Both doors share the SAME dispatch, per-org concurrency bound, credential scope,
// meter (one unit), and audit record — so they can never diverge on what a tool
// does or on who is allowed to run it.
// - connectorToolProvider.Dispatch — the unified tool plane, which is how a
// connector action is reached from POST /v1/tools/call and therefore from the
// fleet's one agent door.
// - InvokeTool — the in-process seam a sibling subsystem (the Business AI guide)
// uses to act as an org without an HTTP hop, metering + auditing with no HTTP
// context.
//
// Both share the SAME dispatch, per-org concurrency bound, credential scope, meter
// (one unit) and audit record — so they can never diverge on what a tool does or
// on who is allowed to run it.
// Dispatch sentinels let each door map a failure onto its own error convention
// (the JSON-RPC door to -32601/-32005, the in-process door to a returned error)
@@ -62,43 +63,9 @@ func dispatchTool(ctx context.Context, org, name string, args map[string]any) (a
})
}
// mcpToolCall is the HTTP JSON-RPC door: it dispatches a tool through the shared
// core and shapes the result/error as JSON-RPC. RunContext is pinned to the
// VALIDATED org by dispatchTool, so a caller can never invoke a tool against
// another tenant's credentials. One metered unit + one audit record per successful
// call; a failed/blocked call is audited as an error and NOT billed.
func mcpToolCall(s *cloud.Service[state], c *zip.Ctx, org string, req mcpRequest) error {
var p struct {
Name string `json:"name"`
Arguments map[string]any `json:"arguments"`
}
body, _ := json.Marshal(req.Params)
_ = json.Unmarshal(body, &p)
out, err := dispatchTool(c.Context(), org, p.Name, p.Arguments)
if err != nil {
switch {
case errors.Is(err, errUnknownTool):
return c.JSON(http.StatusOK, mcpErrorObj(req.ID, -32601, err.Error()))
case errors.Is(err, errToolBusy):
return c.JSON(http.StatusOK, mcpErrorObj(req.ID, -32005, err.Error()))
default:
auditEvent(s, c, org, "automations.mcp.call", p.Name, "error", http.StatusFailedDependency)
return c.JSON(http.StatusOK, mcpErrorObj(req.ID, -32000, err.Error()))
}
}
meterUnit(s, org, c)
auditEvent(s, c, org, "automations.mcp.call", p.Name, "ok", http.StatusOK)
text, _ := json.Marshal(out)
return c.JSON(http.StatusOK, mcpResultObj(req.ID, map[string]any{
"content": []map[string]any{{"type": "text", "text": string(text)}},
}))
}
// InvokeTool runs a single MCP tool as principal `org`, in-process — the same
// dispatch, credential scope, per-org concurrency bound, metering, and audit as
// POST /v1/automations/mcp tools/call, minus the HTTP hop. It is the seam a sibling
// POST /v1/tools/call, minus the HTTP hop. It is the seam a sibling
// subsystem (the Business AI guide) uses to act through the per-principal MCP
// plane. `org` MUST be the caller's VALIDATED principal.Org: the dispatch pins
// every credential and effect to it, so an in-process caller can never exceed that
@@ -127,7 +94,7 @@ func ToolExists(name string) bool {
return ok
}
// auditToolCall appends the MCP tool-call audit record from the in-process door (no
// auditToolCall appends the tool-call audit record from the in-process seam (no
// HTTP context, so no actor sub/email/ip — the org is the attributable actor).
// Nil recorder → no-op. Mirrors auditRun.
func auditToolCall(s *cloud.Service[state], ctx context.Context, org, tool, result string, status int) {
@@ -136,12 +103,12 @@ func auditToolCall(s *cloud.Service[state], ctx context.Context, org, tool, resu
}
rec := audit.Record{
Actor: audit.Actor{Org: org},
Action: "automations.mcp.call",
Action: "automations.tool.call",
Resource: audit.Resource{Type: "automations", ID: tool},
Auth: audit.AuthContext{Method: "in-process"},
Outcome: audit.Outcome{Result: result, Status: status},
}
if _, err := s.State.audit.Append(ctx, rec); err != nil {
s.Log.Warn("audit append failed", "err", err, "action", "automations.mcp.call")
s.Log.Warn("audit append failed", "err", err, "action", "automations.tool.call")
}
}
-139
View File
@@ -1,139 +0,0 @@
package automations
import (
"encoding/json"
"net/http"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/apps/principal"
"github.com/zap-proto/zip"
)
// MCP — the HIP-0300 JSON-RPC 2.0 tool surface at POST /v1/automations/mcp. Every
// connector action is exposed as a tool named "<connector>_<action>", so /v1/agents
// can invoke a connector as an agent tool. GATED: a request without a validated
// principal is refused 403 (never the unscoped tool plane); tools/call dispatches to
// the action's Run with RunContext bound to the caller's VALIDATED org — the same
// isolation boundary the durable activity uses.
// mcpRequest is the JSON-RPC 2.0 request envelope (mirrors tasks/pkg/tasks/mcp.go).
type mcpRequest struct {
JSONRPC string `json:"jsonrpc"`
ID any `json:"id,omitempty"`
Method string `json:"method"`
Params any `json:"params,omitempty"`
}
// mcp is the single JSON-RPC endpoint. Org-gated at the top: no validated principal
// → 403, so a client-forged X-Org-Id with no bearer can never reach a tool.
//
// UNTYPED, for the transport JSON-RPC is. An unparseable body is answered HTTP 200
// carrying a -32700 error OBJECT — that is what the protocol says, and it is what a
// JSON-RPC client parses — while zip's invoke unmarshals the body BEFORE the handler
// runs, so typing this would turn that 200 into a 400 and every method's result and
// error envelope into a shape one Out cannot hold.
//
// Unlike the other three exclusions, no In type reaches this one, because it is closed
// a layer BELOW zip: the decoder is stdlib encoding/json (zip's internal/jsonenc), and
// Unmarshal validates the WHOLE input before dispatching to any UnmarshalJSON. An In of
// json.RawMessage and an In whose UnmarshalJSON never fails were both tried and both
// answer `invalid body: unexpected end of JSON input` 400 — a syntax error is
// unreachable from Go here, so no handler can re-answer it as -32700.
//
// Same refusal, and the same reason, as the header-authed webhooks in apps/integrations.
// The tools this endpoint dispatches are not lost to the projections: tools.Register
// (Mount) publishes every connector action on the unified tool plane.
func mcp(s *cloud.Service[state], c *zip.Ctx) error {
org, ok := principal.Org(c)
if !ok {
return zip.ErrForbidden("a validated principal is required")
}
if !validOrg(org) {
return zip.ErrBadRequest("org must be a DNS-1123 label")
}
var req mcpRequest
if err := json.Unmarshal(c.Body(), &req); err != nil {
return c.JSON(http.StatusOK, mcpErrorObj(nil, -32700, "parse error: "+err.Error()))
}
switch req.Method {
case "initialize":
return c.JSON(http.StatusOK, mcpResultObj(req.ID, map[string]any{
"protocolVersion": "2025-06-18",
"serverInfo": map[string]any{"name": "hanzo-automations", "version": "1.0.0"},
"capabilities": map[string]any{"tools": map[string]any{}},
}))
case "ping":
return c.JSON(http.StatusOK, mcpResultObj(req.ID, map[string]any{}))
case "tools/list":
return c.JSON(http.StatusOK, mcpResultObj(req.ID, map[string]any{"tools": mcpTools()}))
case "tools/call":
return mcpToolCall(s, c, org, req)
default:
return c.JSON(http.StatusOK, mcpErrorObj(req.ID, -32601, "method not found: "+req.Method))
}
}
// mcpToolCall (the tools/call arm) lives in invoke.go — it shares the ONE tool
// dispatch core with the in-process InvokeTool seam.
// mcpTools is the tool catalogue: every connector action, name "<connector>_<action>",
// with an input schema derived from its Props. Stable order (sorted).
func mcpTools() []map[string]any {
tools := make([]map[string]any, 0, 16)
for _, c := range sortedConnectors() {
for _, a := range sortedActions(c) {
tools = append(tools, map[string]any{
"name": c.Name + "_" + a.Name,
"description": a.Description,
"inputSchema": propsToSchema(a.Props),
})
}
}
return tools
}
// propsToSchema derives a JSON-Schema object from an action's Props — the ONE
// mapping from PropSpec to the wire schema, shared by every tool.
func propsToSchema(props []PropSpec) map[string]any {
properties := make(map[string]any, len(props))
required := make([]string, 0, len(props))
for _, p := range props {
properties[p.Name] = map[string]any{"type": jsonType(p.Type), "description": p.Description}
if p.Required {
required = append(required, p.Name)
}
}
return map[string]any{"type": "object", "properties": properties, "required": required}
}
// jsonType normalizes a PropSpec type to a JSON-Schema type (default "string").
func jsonType(t string) string {
switch t {
case "number", "boolean", "object", "array", "string":
return t
default:
return "string"
}
}
// resolveTool maps a "<connector>_<action>" tool name back to its (connector,action)
// pair. Connector and action names both contain underscores, so an unambiguous
// resolution walks the registry rather than splitting the string.
func resolveTool(name string) (connector, action string, ok bool) {
for cn, c := range registry {
for an := range c.Actions {
if cn+"_"+an == name {
return cn, an, true
}
}
}
return "", "", false
}
func mcpResultObj(id any, result any) map[string]any {
return map[string]any{"jsonrpc": "2.0", "id": id, "result": result}
}
func mcpErrorObj(id any, code int, msg string) map[string]any {
return map[string]any{"jsonrpc": "2.0", "id": id, "error": map[string]any{"code": code, "message": msg}}
}
-100
View File
@@ -1,100 +0,0 @@
package automations
import (
"encoding/json"
"strings"
"testing"
)
// TestMCPToolsList: tools/list (with a validated principal) exposes every connector
// action as a "<connector>_<action>" tool.
func TestMCPToolsList(t *testing.T) {
app := newApp(t)
r := reqRaw(t, app, "/v1/automations/mcp", "acme", `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`)
if r.Code != 200 {
t.Fatalf("tools/list want 200, got %d (%s)", r.Code, r.Body)
}
var out struct {
Result struct {
Tools []struct {
Name string `json:"name"`
InputSchema map[string]any `json:"inputSchema"`
} `json:"tools"`
} `json:"result"`
}
if err := json.Unmarshal(r.Body, &out); err != nil {
t.Fatalf("tools/list body: %v (%s)", err, r.Body)
}
names := map[string]bool{}
for _, tl := range out.Result.Tools {
names[tl.Name] = true
}
for _, want := range []string{"slack_send_message", "core_http_request", "core_code", "github_create_issue", "google_append_row", "google_list_files"} {
if !names[want] {
t.Fatalf("tools/list missing %q; got %v", want, names)
}
}
}
// TestMCPToolCallGated: tools/call with NO validated principal is refused 403 — the
// tool plane never serves an unauthenticated caller.
func TestMCPToolCallGated(t *testing.T) {
app := newApp(t)
r := reqRaw(t, app, "/v1/automations/mcp", "",
`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"core_code","arguments":{"x":1}}}`)
if r.Code != 403 {
t.Fatalf("tools/call without principal want 403, got %d (%s)", r.Code, r.Body)
}
}
// TestMCPToolCallDispatches: tools/call with a principal dispatches to the action's
// Run end-to-end (core_code echoes its resolved input).
func TestMCPToolCallDispatches(t *testing.T) {
app := newApp(t)
r := reqRaw(t, app, "/v1/automations/mcp", "acme",
`{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"core_code","arguments":{"greeting":"hello"}}}`)
if r.Code != 200 {
t.Fatalf("tools/call want 200, got %d (%s)", r.Code, r.Body)
}
var out struct {
Result struct {
Content []struct {
Type string `json:"type"`
Text string `json:"text"`
} `json:"content"`
} `json:"result"`
Error *struct {
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal(r.Body, &out); err != nil {
t.Fatalf("tools/call body: %v (%s)", err, r.Body)
}
if out.Error != nil {
t.Fatalf("tools/call unexpected error: %s", out.Error.Message)
}
if len(out.Result.Content) == 0 || !strings.Contains(out.Result.Content[0].Text, `"greeting":"hello"`) {
t.Fatalf("core_code must echo its input, got %+v", out.Result.Content)
}
}
// TestMCPToolCallSlackFailsClosed: invoking slack_send_message with no connection
// (integrations not mounted) fails closed with an honest error — never a fake success
// and never another tenant's token.
func TestMCPToolCallSlackFailsClosed(t *testing.T) {
app := newApp(t)
r := reqRaw(t, app, "/v1/automations/mcp", "acme",
`{"jsonrpc":"2.0","id":9,"method":"tools/call","params":{"name":"slack_send_message","arguments":{"channel":"C1","text":"hi"}}}`)
if r.Code != 200 {
t.Fatalf("tools/call want 200 (JSON-RPC error in body), got %d (%s)", r.Code, r.Body)
}
var out struct {
Error *struct {
Message string `json:"message"`
} `json:"error"`
}
_ = json.Unmarshal(r.Body, &out)
if out.Error == nil || !strings.Contains(out.Error.Message, "slack not connected") {
t.Fatalf("slack must fail closed with 'slack not connected', got %s", r.Body)
}
}
+50
View File
@@ -0,0 +1,50 @@
package automations
// tool.go is the mapping between a connector ACTION and the ONE tool plane: the
// name a tool is known by, and the JSON Schema its arguments have.
//
// There is no MCP door here. Every connector action is published into the unified
// registry by connectorToolProvider (automations.go's tools.Register), which is
// where discovery (GET /v1/tools) and dispatch (POST /v1/tools/call) read it — and
// through that registry it reaches the fleet's ONE agent door. A second JSON-RPC
// envelope at /v1/automations/mcp used to serve the same catalogue from the same
// registry with its own schema derivation; it was a duplicate projection of one
// value, so it is gone rather than dark.
// propsToSchema derives a JSON-Schema object from an action's Props — the ONE
// mapping from PropSpec to the wire schema, shared by every tool.
func propsToSchema(props []PropSpec) map[string]any {
properties := make(map[string]any, len(props))
required := make([]string, 0, len(props))
for _, p := range props {
properties[p.Name] = map[string]any{"type": jsonType(p.Type), "description": p.Description}
if p.Required {
required = append(required, p.Name)
}
}
return map[string]any{"type": "object", "properties": properties, "required": required}
}
// jsonType normalizes a PropSpec type to a JSON-Schema type (default "string").
func jsonType(t string) string {
switch t {
case "number", "boolean", "object", "array", "string":
return t
default:
return "string"
}
}
// resolveTool maps a "<connector>_<action>" tool name back to its (connector,action)
// pair. Connector and action names both contain underscores, so an unambiguous
// resolution walks the registry rather than splitting the string.
func resolveTool(name string) (connector, action string, ok bool) {
for cn, c := range registry {
for an := range c.Actions {
if cn+"_"+an == name {
return cn, an, true
}
}
}
return "", "", false
}
+88
View File
@@ -0,0 +1,88 @@
package automations
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"github.com/hanzoai/cloud/apps/tools"
)
// This subsystem serves NO tool door of its own. Every connector action reaches a
// caller through connectorToolProvider — the ONE projection, registered into the
// unified tool plane at Mount — so these tests exercise that provider directly.
// It is what POST /v1/tools/call dispatches through, and therefore what the
// fleet's one MCP door reaches.
// TestConnectorToolsArePublished: every connector action is published as a
// "<connector>_<action>" tool with a derived input schema.
func TestConnectorToolsArePublished(t *testing.T) {
newApp(t)
list, err := connectorToolProvider{}.List(context.Background(), tools.Scope{Org: "acme"})
if err != nil {
t.Fatalf("List: %v", err)
}
names := map[string]json.RawMessage{}
for _, tl := range list {
names[tl.Name] = tl.Schema
}
for _, want := range []string{"slack_send_message", "core_http_request", "core_code", "github_create_issue", "google_append_row", "google_list_files"} {
schema, ok := names[want]
if !ok {
t.Fatalf("the tool plane is missing %q (%d tools published)", want, len(names))
}
if len(schema) == 0 || !strings.Contains(string(schema), `"type":"object"`) {
t.Errorf("%s has no object input schema: %s", want, schema)
}
}
}
// TestConnectorToolDispatches: a dispatch through the plane runs the action's Run
// end-to-end (core_code echoes its resolved input), bound to the caller's org.
func TestConnectorToolDispatches(t *testing.T) {
newApp(t)
out, err := connectorToolProvider{}.Dispatch(context.Background(),
tools.Principal{Org: "acme"}, "core_code", map[string]any{"greeting": "hello"})
if err != nil {
t.Fatalf("dispatch core_code: %v", err)
}
b, _ := json.Marshal(out)
if !strings.Contains(string(b), `"greeting":"hello"`) {
t.Fatalf("core_code must echo its input, got %s", b)
}
}
// TestConnectorToolFailsClosed: invoking slack_send_message with no connection
// (integrations not mounted) fails closed with an honest error — never a fake
// success and never another tenant's token.
func TestConnectorToolFailsClosed(t *testing.T) {
newApp(t)
_, err := connectorToolProvider{}.Dispatch(context.Background(),
tools.Principal{Org: "acme"}, "slack_send_message", map[string]any{"channel": "C1", "text": "hi"})
if err == nil || !strings.Contains(err.Error(), "slack not connected") {
t.Fatalf("slack must fail closed with 'slack not connected', got %v", err)
}
}
// TestConnectorToolUnknownName: a name no connector answers is ErrUnknownTool, so
// the plane reports the miss rather than dispatching something else.
func TestConnectorToolUnknownName(t *testing.T) {
newApp(t)
_, err := connectorToolProvider{}.Dispatch(context.Background(),
tools.Principal{Org: "acme"}, "no_such_tool", nil)
if !errors.Is(err, tools.ErrUnknownTool) {
t.Fatalf("unknown tool must be ErrUnknownTool, got %v", err)
}
}
// TestInvokeToolRequiresAValidatedOrg: the in-process seam refuses a caller with
// no validated org — the dispatch pins every credential to it, so an unnamed
// caller has no scope to be confined to.
func TestInvokeToolRequiresAValidatedOrg(t *testing.T) {
newApp(t)
if _, err := InvokeTool(context.Background(), "", "core_code", nil); err == nil {
t.Fatalf("InvokeTool with no org must refuse")
}
}
+1 -1
View File
@@ -10,7 +10,7 @@ import (
// connectorToolProvider registers every connector action into the unified tool
// plane (clients/tools) as a SourceConnector tool. It reuses the SAME resolution +
// dispatch the /v1/automations/mcp endpoint uses (resolveTool → lookupAction →
// dispatch the in-process seam uses (resolveTool → lookupAction →
// Action.Run, RunContext.Token pinned to the caller's validated org), so there is
// ONE connector-dispatch path — the tool plane composes it, never re-implements it.
// The plane owns activation, pricing, metering + audit; this provider owns only the
+1 -1
View File
@@ -224,7 +224,7 @@ type ConnectorTrigger struct {
// PropSpec describes one input property of an action/trigger. It is the ONE prop
// shape shared by the connector framework (connector.go), the catalogue, and the
// MCP tool input-schema derivation (mcp.go) — one definition, three consumers.
// Tool input-schema derivation (tool.go) — one definition, every consumer.
type PropSpec struct {
Name string `json:"name"`
DisplayName string `json:"displayName,omitempty"`
+10 -36
View File
@@ -9,15 +9,21 @@ import (
"time"
)
// Four /v1/automations routes are deliberately NOT typed ops, and routes() names the
// Three /v1/automations routes are deliberately NOT typed ops, and routes() names the
// wire fact behind each one. Prose is not a gate: a later reader can retype any of
// them, watch the suite stay green, and ship a silent wire change — three of the four
// break on inputs no existing test sends.
// them, watch the suite stay green, and ship a silent wire change — each one breaks
// on inputs no existing test sends.
//
// There used to be a fourth: the subsystem's own MCP JSON-RPC door, excluded because
// a JSON-RPC envelope answers an unparseable body with HTTP 200. It is gone, not
// retyped — the fleet serves ONE MCP door, on the host, and every connector action
// reaches it through the unified tool plane. A transport nobody duplicates needs no
// exclusion.
//
// These tests pin the FACTS, so the exclusion is enforced rather than asserted. Each
// one fails the moment its route becomes a typed op, and says which fact was lost.
//
// The shared mechanism for three of them is zip's typed-op invoke (typed.go): it
// The shared mechanism for all three is zip's typed-op invoke (typed.go): it
// unmarshals the request body into the op's In BEFORE the handler runs and answers
// `invalid body:` 400 when that fails. So an In can only describe a body whose shape
// is CLOSED and known. Reading raw bytes from cloud.Request(ctx) does NOT recover
@@ -65,38 +71,6 @@ import (
// transports the In is the only channel there is. TestOpsAddressThroughArgumentsAlone
// pins that, so this retype goes red where the REST pins cannot see it.
// TestMCPAnswersUnparseableBody200 pins the JSON-RPC contract on POST
// /v1/automations/mcp: a body that is not JSON is a PROTOCOL result, not a transport
// failure, so it answers HTTP 200 carrying a -32700 (parse error) object. A typed op
// would answer 400 with no JSON-RPC envelope at all, which every conforming client
// reads as a transport failure instead of the parse error it is.
//
// This one is closed at a layer BELOW zip, which is why no In type reaches it: the
// decoder is encoding/json (zip's internal/jsonenc, stdlib only), and Unmarshal
// validates the WHOLE input before it dispatches to any UnmarshalJSON. So neither an
// In of json.RawMessage nor an In whose UnmarshalJSON never fails sees the bytes —
// both answer the same `invalid body: unexpected end of JSON input` 400. A syntax
// error is unreachable from Go, so it cannot be re-answered as -32700 from a handler.
func TestMCPAnswersUnparseableBody200(t *testing.T) {
app := newApp(t)
r := reqRaw(t, app, "/v1/automations/mcp", "acme", `{"jsonrpc":"2.0","id":1,`)
if r.Code != http.StatusOK {
t.Fatalf("unparseable JSON-RPC body must answer 200 (the error rides in the envelope), got %d: %s", r.Code, r.Body)
}
var out struct {
Error struct {
Code int `json:"code"`
} `json:"error"`
}
if err := json.Unmarshal(r.Body, &out); err != nil {
t.Fatalf("body must be a JSON-RPC envelope: %v (%s)", err, r.Body)
}
if out.Error.Code != -32700 {
t.Fatalf("want JSON-RPC parse error -32700, got %d: %s", out.Error.Code, r.Body)
}
}
// TestResumeAcceptsAnyJSONValue pins the resume payload on POST
// /v1/automations/runs/{id}/resume: it is an ARBITRARY JSON value — a number, a
// string, an array, a bool, null or an object — handed verbatim to the waitpoint as
+2 -1
View File
@@ -62,7 +62,8 @@ UpdateData/Installed` (validation + lifecycle hooks run through those).
## Autonomous loop
The `content` automations connector exposes `content_generate`/`content_transition`/
`content_publish` as flow steps AND MCP tools (`POST /v1/automations/mcp`). It calls the
`content_publish` as flow steps AND as tools on the unified plane (`POST /v1/tools/call`,
and therefore on the fleet's one MCP door). It calls the
ops IN-PROCESS (org-scoped by `rc.Org`) — NOT `core.http_request`, whose SSRF guard
blocks internal `/v1/*`. Canonical flow (cron polling trigger):
+1 -1
View File
@@ -28,7 +28,7 @@ func init() {
"Tool.inputSchema": "Schema is the JSON Schema of the call arguments — the MCP inputSchema.\nAbsent for a tool that takes none.",
"Tool.name": "Name is the tool's id in the flat, fleet-wide tool namespace — the value a\ntools/call passes. Unique across sources: a collision is resolved by source\nprecedence before the caller ever sees it.",
"Tool.price": "Price is what a call costs and who is paid, absent for a free tool.\nEnforcement is the x402 settlement seam; this is the declaration.",
"Tool.source": "Source is where the tool comes from: builtin, connector, function,\nzap-service, agent, skill or mcp.",
"Tool.source": "Source is where the tool comes from: connector, function, zap-service,\nagent, skill or mcp.",
"marketCatalog.items": "Items is every capability the caller can see in their own (org, project),\neach carrying any public listing's shop metadata and whether it is installed.",
},
})
-222
View File
@@ -1,222 +0,0 @@
package tools
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"github.com/hanzoai/cloud"
fiber "github.com/zap-proto/fiber/v3"
)
// full-cloud-control (task 3). Every cloud /v1 route becomes a Tool, so a per-user
// token does over MCP exactly what that user may do over HTTP. Dispatch REPLAYS the
// caller's own credential through the SAME Fiber app + middleware chain
// (SanitizeIdentity → BillingGate → the route handler), so authorization is the
// SAME IAM check — never a parallel one. This is the zapface in-process pattern
// generalized to a tool source.
//
// Billing note: a builtin dispatch bills TWICE by design — the tools-plane unit
// (the HTTP layer's meter, product "tools") AND the replayed route's own gate
// (its product, e.g. a provision fee). They are distinct ledger lines, not a
// double-charge of one unit; set CLOUD_TOOLS_FEE_CENTS=0 to make the orchestration
// unit free while the underlying route still charges.
// builtinProvider generates + dispatches tools from the live route table. It holds
// the app so it enumerates routes lazily (at request time), after every subsystem
// has mounted its routes.
type builtinProvider struct {
app cloud.Router
}
func newBuiltinProvider(app cloud.Router) *builtinProvider { return &builtinProvider{app: app} }
func (p *builtinProvider) Source() Source { return SourceBuiltin }
// dispatchMethods are the HTTP verbs exposed as tools (read + write). HEAD/OPTIONS
// are transport, not capabilities.
var dispatchMethods = map[string]bool{
http.MethodGet: true, http.MethodPost: true, http.MethodPut: true,
http.MethodPatch: true, http.MethodDelete: true,
}
// route is one enumerated /v1 route reduced to what a tool needs.
type route struct {
method string
path string // fiber path template, e.g. /v1/agents/:ref/run
params []string // path param names in order
}
// enumerate returns the exposable /v1 routes from the live Fiber stack: concrete
// verbs only, /v1 only, no wildcards (proxy/static), and never the tool plane's own
// endpoints (recursion). This is the ONE route→tool projection, shared by List and
// Dispatch so a tool name resolves identically both ways.
func (p *builtinProvider) enumerate() []route {
if p.app == nil {
return nil
}
var out []route
seen := map[string]bool{}
for _, r := range p.app.Fiber().GetRoutes(true) {
m := strings.ToUpper(r.Method)
if !dispatchMethods[m] {
continue
}
if !strings.HasPrefix(r.Path, "/v1/") {
continue
}
if strings.Contains(r.Path, "*") { // wildcard proxy/static — not a discrete capability.
continue
}
if strings.HasPrefix(r.Path, "/v1/tools/") { // never expose the tool plane through itself.
continue
}
if strings.HasSuffix(r.Path, "/health") {
continue
}
key := m + " " + r.Path
if seen[key] {
continue
}
seen[key] = true
out = append(out, route{method: m, path: r.Path, params: append([]string(nil), r.Params...)})
}
return out
}
func (p *builtinProvider) List(ctx context.Context, scope Scope) ([]Tool, error) {
routes := p.enumerate()
out := make([]Tool, 0, len(routes))
for _, r := range routes {
out = append(out, Tool{
Name: routeToolName(r.method, r.path),
Source: SourceBuiltin,
Description: r.method + " " + r.path,
Schema: routeSchema(r),
Dispatchable: true,
})
}
return out, nil
}
// Dispatch resolves the tool to its route, fills path params + query + body from
// args, and replays the request in-process with the caller's credential. The full
// middleware chain runs, so the route's own auth/billing enforce identically to a
// direct HTTP call.
func (p *builtinProvider) Dispatch(ctx context.Context, pr Principal, name string, args map[string]any) (any, error) {
var target *route
for _, r := range p.enumerate() {
if routeToolName(r.method, r.path) == name {
rr := r
target = &rr
break
}
}
if target == nil {
return nil, ErrUnknownTool
}
// Fill the concrete path from the params in args.
concrete := target.path
for _, param := range target.params {
val, _ := args[param].(string)
concrete = strings.Replace(concrete, ":"+param, url.PathEscape(val), 1)
}
// Optional query string.
if q, ok := args["query"].(map[string]any); ok && len(q) > 0 {
vals := url.Values{}
for k, v := range q {
vals.Set(k, toStr(v))
}
concrete += "?" + vals.Encode()
}
// Optional JSON body (write verbs only).
var body io.Reader
hasBody := false
if target.method != http.MethodGet && args["body"] != nil {
b, err := json.Marshal(args["body"])
if err != nil {
return nil, err
}
body = bytes.NewReader(b)
hasBody = true
}
req := newRequestWithContext(ctx, target.method, concrete, body)
for k, v := range pr.credential {
req.Header.Set(k, v)
}
if hasBody {
req.Header.Set("Content-Type", "application/json")
}
resp, err := p.app.Fiber().Test(req, fiber.TestConfig{Timeout: 0})
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, maxMCPResponse))
out := map[string]any{"status": resp.StatusCode}
var parsed any
if json.Unmarshal(raw, &parsed) == nil {
out["body"] = parsed
} else {
out["body"] = string(raw)
}
return out, nil
}
// routeToolName is the deterministic tool name for a route, used by both List and
// Dispatch. Flat, lowercase, "_"-separated (the automations tool-name style), path
// params kept by name: GET /v1/agents/:ref/run → cloud_get_agents_ref_run.
func routeToolName(method, path string) string {
slug := strings.TrimPrefix(path, "/v1/")
slug = strings.ReplaceAll(slug, "/", "_")
slug = strings.ReplaceAll(slug, ":", "")
slug = strings.Trim(slug, "_")
return "cloud_" + strings.ToLower(method) + "_" + slug
}
// httptest.NewRequestWithContext is a thin helper mirroring httptest.NewRequest but
// carrying ctx, so a client disconnect cancels the in-process replay.
func newRequestWithContext(ctx context.Context, method, target string, body io.Reader) *http.Request {
return httptest.NewRequest(method, target, body).WithContext(ctx)
}
// routeSchema builds the JSON-Schema for a route tool: each path param is a
// required string; query + body are optional objects.
func routeSchema(r route) json.RawMessage {
props := map[string]any{}
required := make([]string, 0, len(r.params))
for _, param := range r.params {
props[param] = map[string]any{"type": "string", "description": "path parameter :" + param}
required = append(required, param)
}
props["query"] = map[string]any{"type": "object", "description": "query string parameters"}
if r.method != http.MethodGet {
props["body"] = map[string]any{"type": "object", "description": "JSON request body"}
}
schema := map[string]any{"type": "object", "properties": props}
if len(required) > 0 {
schema["required"] = required
}
b, _ := json.Marshal(schema)
return b
}
func toStr(v any) string {
switch x := v.(type) {
case string:
return x
case nil:
return ""
default:
b, _ := json.Marshal(x)
return strings.Trim(string(b), `"`)
}
}
+58 -163
View File
@@ -2,7 +2,6 @@ package tools
import (
"context"
"encoding/json"
"errors"
"net/http"
"strings"
@@ -29,8 +28,8 @@ const (
// `?activated` have always meant "no filter". A bool field would make zip's
// binder read both as true, which is a different set of tools for the same URL.
type toolQuery struct {
// Source keeps only tools from one source — builtin, connector, function,
// zap-service, agent, skill or mcp. Empty keeps every source.
// Source keeps only tools from one source — connector, function, zap-service,
// agent, skill or mcp. Empty keeps every source.
Source string `json:"source"`
// Activated keeps only the tools activated for the caller's org and project,
// and only when it is exactly the string "true".
@@ -47,10 +46,10 @@ type toolList struct {
// ListTools lists every tool the caller's org and project can reach, from every
// source, each flagged with whether it is activated. This is the discovery
// surface: one flat set of names spanning builtin cloud controls, connector
// actions, user functions, zap-service routes, agents, skills and the org's own
// external MCP servers, deduplicated by name so the highest-precedence source
// wins a collision. It lists; it does not call — dispatch is the MCP endpoint.
// surface: one flat set of names spanning connector actions, user functions,
// zap-service routes, agents, skills and the org's own external MCP servers,
// deduplicated by name so the highest-precedence source wins a collision. It
// lists; it does not call — dispatch is POST /v1/tools/call.
func (o toolOps) listTools(ctx context.Context, in *toolQuery) (*toolList, error) {
scope, err := scopeOf(ctx)
if err != nil {
@@ -72,168 +71,72 @@ func (o toolOps) listTools(ctx context.Context, in *toolQuery) (*toolList, error
return &toolList{Tools: out}, nil
}
// ── POST /v1/tools/mcp — the unified MCP JSON-RPC surface ───────────────────────
// ── POST /v1/tools/call — the DYNAMIC half of the tool plane ───────────────────
// toolCall names a tool and the arguments to run it with.
type toolCall struct {
// Name is the tool to run, exactly as GET /v1/tools reports it.
Name string `json:"name"`
// Arguments is the tool's own input object, passed through verbatim to
// whichever source owns it.
Arguments map[string]any `json:"arguments"`
}
// toolResult is what the tool returned.
type toolResult struct {
// Name is the tool that ran.
Name string `json:"name"`
// Result is the tool's own output, verbatim — its shape is the tool's, not
// this plane's.
Result any `json:"result"`
}
// CallTool runs one of the caller's activated tools and answers with its output.
//
// UNTYPED BY DESIGN, and it is the wire that says so — twice over. See
// untypedByDesign in typed_wire_test.go, which holds this route as a closed list
// entry so a later reader cannot mistake it for an oversight.
// This is the door onto the tool plane's DYNAMIC half — the half no build-time
// catalogue can hold, because it is per-tenant: an org's connected connector
// actions, its authored skills, its agents and functions, and the tools of every
// external MCP server it registered. A tool's existence, its price and its
// activation are all rows, not code, so they cannot be known until the caller is.
//
// 1. It is deliberately BODY-TOLERANT: a body that is not JSON answers HTTP 200
// carrying the JSON-RPC parse error (-32700), the MCP convention. A typed op
// cannot express that — zip's op.invoke unconditionally 400s on any
// unparseable non-empty body (zip@v1.18.11/typed.go:242) before the handler
// runs, so typing this route turns every one of those 200s into a 400.
// 2. Its request and its response are JSON-RPC ENVELOPES whose shape depends on
// `method`: params is `any` (tools/call reads name+arguments, initialize and
// ping read nothing), and the result is a different object per method. One In
// and one Out cannot describe that without publishing a shape the wire does
// not carry.
// mcpRequest is the JSON-RPC envelope this route accepts. Staying untyped costs
// prose, an MCP tool and a CLI command — it does not have to cost the SHAPE, so
// this struct is DECLARED through openapi.Register (tools.go's init). Without
// that declaration the operation renders with no requestBody at all, which is
// what a route taking no input publishes: every SDK generated off the document
// offered an MCP call with nowhere to put the call.
type mcpRequest struct {
// JSONRPC is the protocol version. Always "2.0".
JSONRPC string `json:"jsonrpc"`
// ID correlates the answer with this call. Any JSON value; absent for a
// notification, and echoed back verbatim.
ID any `json:"id,omitempty"`
// Method is the JSON-RPC method: initialize, ping, tools/list or tools/call.
Method string `json:"method"`
// Params are the method's arguments, and their shape depends on the method —
// tools/call reads name + arguments, initialize and ping read nothing. That
// per-method shape is the second reason this route is not a typed op.
Params any `json:"params,omitempty"`
}
// mcpResponse is the JSON-RPC envelope EVERY answer on this route carries:
// jsonrpc and id, then exactly one of result or error. It is a named type so the
// response can be DECLARED (openapi.Register, tools.go's init) and so the
// declaration and the value the handler returns are the same shape — a map
// literal here and a struct in the document is how the two drift apart.
// One policy, the registry's: resolve by precedence, refuse an unactivated tool
// 403, settle a priced one through the x402 seam or fail closed 402, then
// dispatch to the winning source bound to the caller's own (org, project). One
// metered unit, one audit record. A caller can only ever dispatch its own tools.
//
// The fields are ALPHABETICAL because the wire they replace was a Go map, and
// encoding/json writes a map in sorted key order. TestMCPEnvelopeIsByteIdentical
// pins that, so naming the shape cannot move a byte of it.
type mcpResponse struct {
// Error is the JSON-RPC error, present only on a failure. A parse error
// (-32700), an unknown method (-32601), missing params (-32602) and a tool
// that failed (-32000) all arrive here under HTTP 200 — the MCP convention.
Error *rpcErrorBody `json:"error,omitempty"`
// ID echoes the request's id, and is null when the request carried none — a
// body that did not parse leaves nothing to echo.
ID any `json:"id"`
// JSONRPC is the protocol version. Always "2.0".
JSONRPC string `json:"jsonrpc"`
// Result is the method's answer, present only on success. Its shape depends
// on the method: initialize returns the server info, tools/list a tools
// array, tools/call a content array.
Result any `json:"result,omitempty"`
}
// rpcErrorBody is one JSON-RPC error object. Alphabetical for the same reason.
type rpcErrorBody struct {
// Code is the JSON-RPC error code.
Code int `json:"code"`
// Message says what failed, in prose.
Message string `json:"message"`
}
// mcp is the single JSON-RPC endpoint spanning EVERY source. Org-gated at the top:
// no validated principal → 403, so a client-forged X-Org-Id with no credential can
// never reach a tool. tools/list returns only the caller's ACTIVATED, dispatchable
// tools (the agent-facing set); tools/call dispatches through the registry's ONE
// per-principal plane (activation gate → price gate → dispatch).
func mcp(s *cloud.Service[state], c *zip.Ctx) error {
p, ok := PrincipalFrom(c)
if !ok {
return zip.ErrForbidden("a validated principal is required")
// Discovery is GET /v1/tools — ?activated=true for the callable set.
//
// Example: {"name": "slack_post_message", "arguments": {"channel": "#general", "text": "hi"}}
func (o toolOps) callTool(ctx context.Context, in *toolCall) (*toolResult, error) {
p, err := principalOf(ctx)
if err != nil {
return nil, err
}
var req mcpRequest
if err := json.Unmarshal(c.Body(), &req); err != nil {
return c.JSON(http.StatusOK, rpcError(nil, -32700, "parse error: "+err.Error()))
name := strings.TrimSpace(in.Name)
if !validToolName(name) {
return nil, zip.ErrBadRequest("name is required and must be a tool name")
}
switch req.Method {
case "initialize":
return c.JSON(http.StatusOK, rpcResult(req.ID, map[string]any{
"protocolVersion": "2025-06-18",
"serverInfo": map[string]any{"name": "hanzo-tools", "version": "1.0.0"},
"capabilities": map[string]any{"tools": map[string]any{}},
}))
case "ping":
return c.JSON(http.StatusOK, rpcResult(req.ID, map[string]any{}))
case "tools/list":
return c.JSON(http.StatusOK, rpcResult(req.ID, map[string]any{"tools": mcpToolList(s, c, p)}))
case "tools/call":
return mcpToolCall(s, c, p, req)
default:
return c.JSON(http.StatusOK, rpcError(req.ID, -32601, "method not found: "+req.Method))
}
}
// mcpToolList projects the ACTIVATED, dispatchable tools into the MCP tool shape.
func mcpToolList(s *cloud.Service[state], c *zip.Ctx, p Principal) []map[string]any {
all := Default().List(c.Context(), Scope{Org: p.Org, Project: p.Project})
out := make([]map[string]any, 0, len(all))
for _, t := range all {
if !t.Activated || !t.Dispatchable {
continue
}
schema := t.Schema
if len(schema) == 0 {
schema = json.RawMessage(`{"type":"object","properties":{}}`)
}
out = append(out, map[string]any{
"name": t.Name,
"description": t.Description,
"inputSchema": schema,
})
}
return out
}
// mcpToolCall dispatches one tool through the registry. Authorization failures
// (unactivated) map to HTTP 403 and payment failures to 402 — the SAME contract the
// rest of the plane uses — while an unknown tool / runtime error stays a JSON-RPC
// error object (HTTP 200), the MCP convention. One metered unit + one audit record.
func mcpToolCall(s *cloud.Service[state], c *zip.Ctx, p Principal, req mcpRequest) error {
var params struct {
Name string `json:"name"`
Arguments map[string]any `json:"arguments"`
}
body, _ := json.Marshal(req.Params)
_ = json.Unmarshal(body, &params)
if params.Name == "" {
return c.JSON(http.StatusOK, rpcError(req.ID, -32602, "params.name is required"))
}
out, err := Default().Dispatch(c.Context(), p, params.Name, params.Arguments)
out, err := Default().Dispatch(ctx, p, name, in.Arguments)
if err != nil {
switch {
case errors.Is(err, ErrUnknownTool):
return c.JSON(http.StatusOK, rpcError(req.ID, -32601, "unknown tool: "+params.Name))
return nil, zip.ErrNotFound("unknown tool: " + name)
case errors.Is(err, ErrNotActivated):
audrecord(s, c, p.Org, params.Name, "denied", http.StatusForbidden)
return zip.ErrForbidden("tool not activated for this org/project: " + params.Name)
o.audit(ctx, "tools.call", p.Org, name, "denied", http.StatusForbidden)
return nil, zip.ErrForbidden("tool not activated for this org/project: " + name)
case errors.Is(err, ErrNotDispatchable):
return zip.Errorf(http.StatusUnprocessableEntity, "tool is not dispatchable: %s", params.Name)
return nil, zip.Errorf(http.StatusUnprocessableEntity, "tool is not dispatchable: %s", name)
case errors.Is(err, ErrPaymentRequired), errors.Is(err, ErrChargerUnset):
audrecord(s, c, p.Org, params.Name, "payment_required", http.StatusPaymentRequired)
return zip.Errorf(http.StatusPaymentRequired, "payment required for tool: %s", params.Name)
o.audit(ctx, "tools.call", p.Org, name, "payment_required", http.StatusPaymentRequired)
return nil, zip.Errorf(http.StatusPaymentRequired, "payment required for tool: %s", name)
default:
audrecord(s, c, p.Org, params.Name, "error", http.StatusFailedDependency)
return c.JSON(http.StatusOK, rpcError(req.ID, -32000, err.Error()))
o.audit(ctx, "tools.call", p.Org, name, "error", http.StatusFailedDependency)
return nil, zip.Errorf(http.StatusFailedDependency, "tool call failed: %v", err)
}
}
meterUnit(s, c)
audrecord(s, c, p.Org, params.Name, "ok", http.StatusOK)
text, _ := json.Marshal(out)
return c.JSON(http.StatusOK, rpcResult(req.ID, map[string]any{
"content": []map[string]any{{"type": "text", "text": string(text)}},
}))
o.meter(ctx)
o.audit(ctx, "tools.call", p.Org, name, "ok", http.StatusOK)
return &toolResult{Name: name, Result: out}, nil
}
// ── activation API (task 5) ─────────────────────────────────────────────────────
@@ -434,14 +337,6 @@ func (o toolOps) deleteServer(ctx context.Context, in *serverRef) (*noContent, e
// ── shared helpers ──────────────────────────────────────────────────────────────
func rpcResult(id, result any) *mcpResponse {
return &mcpResponse{ID: id, JSONRPC: "2.0", Result: result}
}
func rpcError(id any, code int, msg string) *mcpResponse {
return &mcpResponse{Error: &rpcErrorBody{Code: code, Message: msg}, ID: id, JSONRPC: "2.0"}
}
// validToolName bounds an activation target: the flat tool-name shape every source
// emits ([a-z0-9._:/-] plus "_"), so a hostile name can't become a store-key trick.
func validToolName(name string) bool {
+46 -78
View File
@@ -17,7 +17,7 @@ import (
)
// newApp resets the process-wide registry to a fresh one, mounts the tools plane on
// a fresh app, and lets a test register extra /v1 routes (for the builtin source).
// a fresh app, and lets a test register extra /v1 routes.
func newApp(t *testing.T, extra func(*zip.App)) *zip.App {
t.Helper()
old := std
@@ -73,52 +73,51 @@ func do(t *testing.T, app *zip.App, method, path, org string, body any) result {
return result{Code: resp.StatusCode, Body: b}
}
func rpc(t *testing.T, app *zip.App, org, raw string) result {
// call runs POST /v1/tools/call — the ONE dispatch door onto the dynamic plane.
func call(t *testing.T, app *zip.App, org, name string, args map[string]any) result {
t.Helper()
rq := httptest.NewRequest(http.MethodPost, "/v1/tools/mcp", bytes.NewReader([]byte(raw)))
rq.Header.Set("Content-Type", "application/json")
if org != "" {
rq.Header.Set("X-Org-Id", org)
rq.Header.Set("X-User-Id", "u-"+org)
if args == nil {
args = map[string]any{}
}
resp, err := app.Fiber().Test(rq, fiber.TestConfig{Timeout: 0})
if err != nil {
t.Fatalf("mcp: %v", err)
}
defer func() { _ = resp.Body.Close() }()
b, _ := io.ReadAll(resp.Body)
return result{Code: resp.StatusCode, Body: b}
return do(t, app, http.MethodPost, "/v1/tools/call", org,
map[string]any{"name": name, "arguments": args})
}
// TestMCPGate403: the unified MCP endpoint refuses a caller with no validated
// principal — the tool plane never serves an unauthenticated request.
func TestMCPGate403(t *testing.T) {
// activated lists the caller's callable tools — GET /v1/tools?activated=true, the
// discovery half the dispatch door is paired with.
func activated(t *testing.T, app *zip.App, org string) []string {
t.Helper()
return toolNames(t, do(t, app, http.MethodGet, "/v1/tools?activated=true", org, nil).Body)
}
// TestCallGate403: the dispatch door refuses a caller with no validated principal —
// the tool plane never dispatches an unauthenticated request.
func TestCallGate403(t *testing.T) {
app := newApp(t, nil)
r := rpc(t, app, "", `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`)
r := call(t, app, "", "acme_hello", nil)
if r.Code != 403 {
t.Fatalf("mcp without principal want 403, got %d (%s)", r.Code, r.Body)
t.Fatalf("tools/call without principal want 403, got %d (%s)", r.Code, r.Body)
}
}
// TestActivationAndMCPCall: the full activation round-trip. A registered source's
// tool is invisible + un-callable until activated via PUT /v1/tools/activation;
// once activated it appears in tools/list and tools/call dispatches; an unactivated
// tool is refused 403.
func TestActivationAndMCPCall(t *testing.T) {
// TestActivationAndCall: the full activation round-trip. A registered source's tool
// is not callable and not in the activated listing until it is switched on via PUT
// /v1/tools/activation; once activated it is listed and POST /v1/tools/call
// dispatches it; an unactivated sibling is refused 403.
func TestActivationAndCall(t *testing.T) {
app := newApp(t, nil)
std.Register(&fakeProvider{src: SourceConnector, tools: []Tool{
tool("acme_hello", SourceConnector),
tool("acme_secret", SourceConnector),
}})
// Before activation: tools/list is empty, tools/call is 403.
list := rpc(t, app, "acme", `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`)
if names := toolNames(t, list.Body); len(names) != 0 {
t.Fatalf("pre-activation tools/list must be empty, got %v", names)
// Before activation: the activated listing is empty, dispatch is 403.
if names := activated(t, app, "acme"); len(names) != 0 {
t.Fatalf("pre-activation activated listing must be empty, got %v", names)
}
call := rpc(t, app, "acme", `{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"acme_hello","arguments":{}}}`)
if call.Code != 403 {
t.Fatalf("unactivated tools/call want 403, got %d (%s)", call.Code, call.Body)
r := call(t, app, "acme", "acme_hello", nil)
if r.Code != 403 {
t.Fatalf("unactivated tools/call want 403, got %d (%s)", r.Code, r.Body)
}
// Activate one tool via the activation API.
@@ -132,23 +131,22 @@ func TestActivationAndMCPCall(t *testing.T) {
t.Fatalf("activation list must contain acme_hello, got %s", get.Body)
}
// tools/list now shows ONLY the activated tool.
list = rpc(t, app, "acme", `{"jsonrpc":"2.0","id":3,"method":"tools/list"}`)
names := toolNames(t, list.Body)
// The activated listing now shows ONLY the activated tool.
names := activated(t, app, "acme")
if len(names) != 1 || names[0] != "acme_hello" {
t.Fatalf("post-activation tools/list must be [acme_hello], got %v", names)
t.Fatalf("post-activation activated listing must be [acme_hello], got %v", names)
}
// tools/call dispatches the activated tool.
call = rpc(t, app, "acme", `{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"acme_hello","arguments":{}}}`)
if call.Code != 200 || !bytes.Contains(call.Body, []byte(`\"by\":\"connector\"`)) {
t.Fatalf("activated tools/call must dispatch on connector, got %d (%s)", call.Code, call.Body)
// tools/call dispatches the activated tool, and answers with its own output.
r = call(t, app, "acme", "acme_hello", nil)
if r.Code != 200 || !bytes.Contains(r.Body, []byte(`"by":"connector"`)) {
t.Fatalf("activated tools/call must dispatch on connector, got %d (%s)", r.Code, r.Body)
}
// The still-unactivated sibling stays 403 (activation is per-tool).
call = rpc(t, app, "acme", `{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"acme_secret","arguments":{}}}`)
if call.Code != 403 {
t.Fatalf("sibling unactivated tools/call want 403, got %d (%s)", call.Code, call.Body)
r = call(t, app, "acme", "acme_secret", nil)
if r.Code != 403 {
t.Fatalf("sibling unactivated tools/call want 403, got %d (%s)", r.Code, r.Body)
}
}
@@ -227,34 +225,6 @@ func TestExternalMCPDispatch(t *testing.T) {
}
}
// TestBuiltinRouteTool: full-cloud-control — an arbitrary /v1 route becomes a tool
// and dispatches IN-PROCESS through the same Fiber app, returning its response.
func TestBuiltinRouteTool(t *testing.T) {
app := newApp(t, func(a *zip.App) {
a.Get("/v1/ping", func(c *zip.Ctx) error {
return c.JSON(http.StatusOK, map[string]any{"pong": true})
})
})
// The route surfaces as a builtin tool.
p := newBuiltinProvider(app)
tools, _ := p.List(context.Background(), Scope{Org: "acme"})
found := false
for _, tl := range tools {
if tl.Name == "cloud_get_ping" {
found = true
}
}
if !found {
t.Fatalf("builtin must expose cloud_get_ping, got %d tools", len(tools))
}
// Activate + dispatch through the FULL registry+HTTP path.
do(t, app, http.MethodPut, "/v1/tools/activation", "acme", map[string]any{"activate": []string{"cloud_get_ping"}})
call := rpc(t, app, "acme", `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"cloud_get_ping","arguments":{}}}`)
if call.Code != 200 || !bytes.Contains(call.Body, []byte(`\"pong\":true`)) {
t.Fatalf("builtin dispatch must return the route response, got %d (%s)", call.Code, call.Body)
}
}
// TestSSRFGuard: the registration boundary rejects non-public / metadata targets.
func TestSSRFGuard(t *testing.T) {
bad := []string{
@@ -298,17 +268,15 @@ func (f fakeKMS) Sign(_ context.Context, _ string, _ []byte) ([]byte, error) {
func toolNames(t *testing.T, body []byte) []string {
t.Helper()
var out struct {
Result struct {
Tools []struct {
Name string `json:"name"`
} `json:"tools"`
} `json:"result"`
Tools []struct {
Name string `json:"name"`
} `json:"tools"`
}
if err := json.Unmarshal(body, &out); err != nil {
t.Fatalf("decode tools/list: %v (%s)", err, body)
t.Fatalf("decode tool listing: %v (%s)", err, body)
}
names := make([]string, 0, len(out.Result.Tools))
for _, tl := range out.Result.Tools {
names := make([]string, 0, len(out.Tools))
for _, tl := range out.Tools {
names = append(names, tl.Name)
}
return names
+13 -21
View File
@@ -9,19 +9,20 @@ import (
"github.com/zap-proto/zip"
)
// The separately-listed registries: /v1/skills, /v1/mcp, /v1/plugins.
// The separately-listed registries: /v1/skills, /v1/mcp/servers, /v1/plugins.
//
// /v1/skills and /v1/mcp are the SAME registry as /v1/tools viewed through one
// Source each, so a client asking "what skills does this org have" does not
// have to know to pass ?source=skill. They are views, not stores — a tool is
// still registered in exactly one place (tools.Register) and activation lives
// in exactly one place (ActivationStore), which is what keeps a source from
// drifting into its own half-parallel plane.
// /v1/skills is the SAME registry as /v1/tools viewed through one Source, so a
// client asking "what skills does this org have" does not have to know to pass
// ?source=skill. It is a view, not a store — a tool is still registered in
// exactly one place (tools.Register) and activation lives in exactly one place
// (ActivationStore), which is what keeps a source from drifting into its own
// half-parallel plane.
//
// /v1/mcp additionally owns the EXTERNAL MCP SERVER registry (the connection
// records), because a server is a thing an org creates and deletes, not a tool
// the registry enumerates. That surface MOVED here from /v1/tools/servers — it
// was not copied.
// /v1/mcp/servers owns the EXTERNAL MCP SERVER registry (the connection records),
// because a server is a thing an org creates and deletes, not a tool the registry
// enumerates. The tools those servers offer are reported by GET /v1/tools with
// ?source=mcp — there is no second view of them, and /v1/mcp itself is the
// FLEET's one agent door, served by the host.
//
// /v1/plugins is deliberately NOT a tool source. A plugin here is a mounted
// subsystem (cloud.Plugin: Name, Mount, Price, Prefixes) — code that extends
@@ -60,16 +61,7 @@ func (o toolOps) listSkills(ctx context.Context, in *sourceQuery) (*sourceToolLi
return o.bySource(ctx, SourceSkill, in)
}
// ListMCPTools lists the tools reachable on the external MCP servers the caller's
// org has registered, with each one's activation flag. Every name is prefixed by
// the server id it came from, which is what keeps two servers offering "search"
// from colliding. It is GET /v1/tools narrowed to one source, so an external tool
// still cannot shadow a native one — mcp is the lowest-precedence source.
func (o toolOps) listMCPTools(ctx context.Context, in *sourceQuery) (*sourceToolList, error) {
return o.bySource(ctx, SourceMCP, in)
}
// bySource is the ONE body behind the two source views: it filters the
// bySource is the ONE body behind the source view: it filters the
// PER-PRINCIPAL list, so activation and precedence are already applied and a
// source view can never widen what the caller may see.
func (o toolOps) bySource(ctx context.Context, src Source, in *sourceQuery) (*sourceToolList, error) {
+29 -50
View File
@@ -1,9 +1,16 @@
// Package tools is the ONE tool plane for Hanzo Cloud: a single registry where
// every callable capability — a connector action, a user function, a zap service
// route, a cloud /v1 control ("full-cloud-control"), an agent, a skill, or a tool
// on an org's own external MCP server — is a Tool with a Source, a JSON-Schema, a
// Package tools is the ONE tool plane for Hanzo Cloud's PER-TENANT capabilities:
// a single registry where every callable thing an ORG owns — a connector action, a
// user function, a zap service route, an agent, an authored skill, or a tool on
// the org's own external MCP server — is a Tool with a Source, a JSON-Schema, a
// per-(org,project) activation state, and an optional price.
//
// Per-tenant is the whole boundary. Cloud's OWN typed ops are not here and never
// were a Source: they are code, known at build time, and the fleet publishes them
// as MCP tools straight from the typed-op registry onto the host's one door
// (plugin/<app>/mcp.json → zip.Plugin.Tools). What lives here is ROWS — a tool
// whose existence, price and activation depend on which org is asking — reached
// from that same door through the typed POST /v1/tools/call.
//
// Decomplected on the Rich Hickey seam: a Source knows how to LIST its tools and
// DISPATCH one; the registry knows nothing about how any single source works. Each
// source REGISTERS a Provider into the registry from its own Mount — no source
@@ -13,8 +20,7 @@
// Every dispatch flows through ONE per-principal plane: the caller's VALIDATED org
// (principal.Org) gates the call, the tool must be ACTIVATED for that (org,project)
// or the call is 403, a priced tool settles through the explicit x402 Charger seam,
// and the platform meters one unit. The same isolation boundary the connectors MCP
// endpoint (clients/automations) already ships — generalized across every source.
// and the platform meters one unit. One plane, one policy, every source.
package tools
import (
@@ -30,10 +36,6 @@ import (
type Source string
const (
// SourceBuiltin is a cloud /v1 route exposed as a tool ("full-cloud-control"):
// a per-user token does over MCP exactly what that user may do over HTTP, gated
// by the SAME IAM check because dispatch replays the request in-process.
SourceBuiltin Source = "builtin"
// SourceConnector is a connector action from clients/automations.
SourceConnector Source = "connector"
// SourceFunction is a user-defined function from clients/functions.
@@ -50,12 +52,11 @@ const (
)
// precedence ranks sources so a name collision resolves deterministically: the
// LOWEST rank wins. A native cloud control (builtin) can never be shadowed by an
// org's external MCP server; a first-party connector outranks an external tool of
// the same name. This is the ONE precedence policy, honored by both List (dedup)
// and Dispatch (which source runs).
// LOWEST rank wins. A first-party connector outranks an external tool of the same
// name, and an org's external MCP server ranks last, so it can never shadow
// anything first-party. This is the ONE precedence policy, honored by both List
// (dedup) and Dispatch (which source runs).
var precedence = map[Source]int{
SourceBuiltin: 0,
SourceConnector: 1,
SourceFunction: 2,
SourceZAPService: 3,
@@ -92,8 +93,8 @@ type Tool struct {
// tools/call passes. Unique across sources: a collision is resolved by source
// precedence before the caller ever sees it.
Name string `json:"name"`
// Source is where the tool comes from: builtin, connector, function,
// zap-service, agent, skill or mcp.
// Source is where the tool comes from: connector, function, zap-service,
// agent, skill or mcp.
Source Source `json:"source"`
// Description is the prose a model reads to decide whether to call the tool.
Description string `json:"description"`
@@ -120,54 +121,32 @@ type Scope struct {
}
// Principal is the VALIDATED caller a dispatch runs as — resolved once from the
// request and threaded to every source. Org/Project/User/Owner/IsAdmin are the
// IAM-native identity; credential is the caller's OWN credential headers, replayed
// by a builtin tool so a /v1 route runs under the SAME IAM check as a direct HTTP
// call (cloud's SanitizeIdentity strips minted headers on ingress and re-mints them
// ONLY from a re-validated credential, so replaying the credential — never the
// minted headers — is the one way an in-process call carries the caller's identity;
// this is exactly the zapface in-process-dispatch contract).
// request and threaded to every source. It is the IAM-native identity and nothing
// else: cloud's SanitizeIdentity strips minted authority headers on ingress and
// re-mints them ONLY from a re-validated credential, so by the time a dispatch
// sees a Principal the authority question is already settled upstream.
type Principal struct {
Org string
Project string
User string
Owner string
IsAdmin bool
credential map[string]string
}
// credentialHeaders are the request headers a builtin replay carries: the caller's
// own credential (which cloud re-validates) plus request-shaping context. NO minted
// authority header (X-Org-Id/X-User-Id/…) is replayed — those are stripped on
// ingress and re-derived from the credential, so replaying them is a no-op at best
// and confusing at worst.
var credentialHeaders = []string{"Authorization", "X-Authorization", "Cookie", "Accept-Language", "X-Forwarded-For"}
// PrincipalFrom resolves the validated caller from a request context. It returns
// ok=false (the caller must answer 403) unless a validated principal carries a
// non-empty org — the SAME gate principal.Org enforces. Because c.User() is set
// ONLY from a re-validated credential, a successful resolve guarantees a replayable
// credential is present; it is captured so a builtin tool can act as this exact
// caller and never escalate.
// non-empty org — the SAME gate principal.Org enforces.
func PrincipalFrom(c *zip.Ctx) (Principal, bool) {
org, ok := principal.Org(c)
if !ok {
return Principal{}, false
}
cred := map[string]string{}
for _, h := range credentialHeaders {
if v := c.Header(h); v != "" {
cred[h] = v
}
}
return Principal{
Org: org,
Project: principal.Project(c),
User: c.User(),
Owner: principal.Owner(c),
IsAdmin: c.IsAdmin(),
credential: cred,
Org: org,
Project: principal.Project(c),
User: c.User(),
Owner: principal.Owner(c),
IsAdmin: c.IsAdmin(),
}, true
}
@@ -180,7 +159,7 @@ type Provider interface {
Source() Source
// List returns the tools this source offers for scope. It is scope-aware:
// a connector source lists an org's connected connectors; the external-MCP
// source lists tools from the org's registered servers; builtin lists routes.
// source lists tools from the org's registered servers.
List(ctx context.Context, scope Scope) ([]Tool, error)
// Dispatch invokes the named tool with JSON args, bound to the principal, and
// returns the JSON-encodable result. A source that only lists (skills) returns
+19 -24
View File
@@ -14,19 +14,19 @@ import (
"github.com/zap-proto/zip"
)
// The two routes on this plane that cannot be typed ops still have to be
// The ONE route on this plane that cannot be a typed op still has to be
// DESCRIBED. "Carries no zip registry entry" was being read as "publishes
// nothing", and those are opposite facts: both rendered as an operationId and a
// nothing", and those are opposite facts: it rendered as an operationId and a
// tag and NO BODY AT ALL, which is exactly what a route taking no input and
// returning none publishes. No consumer of the document can tell the two apart,
// so every SDK generated off openapi.yaml offered an MCP call with nowhere to put
// the JSON-RPC envelope and a plugin build with nowhere to put the source.
// so every SDK generated off openapi.yaml offered a plugin build with nowhere to
// put the source.
//
// openapi.Register is the seam for the halves that ARE statable. It attaches to a
// route the router already carries, so it can never contradict the router, and it
// does NOT make these typed ops: there is still no prose, no MCP tool, no CLI
// command and no SDK method, because those come from zip's registry alone. See
// untypedByDesign in typed_wire_test.go for why each one stays out of it.
// untypedByDesign in typed_wire_test.go for why it stays out of it.
//
// What is still NOT declared here, honestly — each a missing capability, not a
// missing edit, so none is papered over with prose that overstates:
@@ -34,24 +34,20 @@ import (
// - the builder's 422 diagnostics. apply states the success shape under the
// "2XX" range key only; the failure body needs the same declarable-error-body
// capability that keeps this route untyped in the first place.
// - the MCP envelope's per-method result. One response schema cannot say that
// `result` is server info for initialize and a tools array for tools/list, so
// it is declared as what it is: unconstrained JSON.
// - field prose on all four shapes. zipdoc lifts doc comments off TYPED ops
// - field prose on both shapes. zipdoc lifts doc comments off TYPED ops
// only, and Register's reflection seam reads Go types, not comments — so
// buildOut publishes `bytes: integer` with no description. AuthoredPlugin is
// unaffected: it is already described through the typed ops that share it,
// and Fold merges the typed schema over this one.
func init() {
openapi.Register("/v1/tools/mcp", "POST", mcpRequest{}, mcpResponse{})
openapi.Register("/v1/plugins/build", "POST", buildRequest{}, buildOut{})
}
// Mount wires the unified tool plane at /v1/tools/* and installs the two providers
// this package OWNS — builtin (full-cloud-control over the live route table) and
// the external-MCP-server source. Every OTHER source (connectors, functions,
// agents, skills) registers its own Provider from its own Mount via tools.Register,
// so this package never learns how another source lists or runs its tools.
// this package OWNS — the external-MCP-server source and the org's own authored
// skills. Every OTHER source (connectors, functions, agents, agent skills)
// registers its own Provider from its own Mount via tools.Register, so this
// package never learns how another source lists or runs its tools.
//
// The process-wide registry (std) is populated by those Register calls regardless
// of mount order; Mount only installs the activation store + this package's
@@ -116,10 +112,9 @@ func Mount(app cloud.Router, deps cloud.Deps) error {
}
// Install the activation store on the process-wide registry and register the
// providers this package owns: builtin, the external-MCP-server source, and
// the org's own skills.
// providers this package owns: the external-MCP-server source and the org's
// own authored skills.
std.SetActivation(activation)
std.Register(newBuiltinProvider(app))
std.Register(newMCPProvider(servers, deps.KMS))
std.Register(orgSkillProvider{store: skills})
@@ -155,9 +150,9 @@ func Mount(app cloud.Router, deps cloud.Deps) error {
// registration order, so one installed here — below the app-level Use — would be
// redundant, and one installed after these leaves would never run.
//
// TYPED ops are the 14 that could be described without moving their wire; the two
// that could not are registered untyped below, each named at its own definition
// with the reason (http.go's mcp, pluginbuild.go's buildPlugin).
// TYPED ops are all of them but one; the one that could not be described without
// moving its wire is registered untyped below, named at its own definition with
// the reason (pluginbuild.go's buildPlugin).
func routes(app cloud.Router, s *cloud.Service[state]) {
v1 := app.Group("/v1")
o := toolOps{s: s}
@@ -166,7 +161,7 @@ func routes(app cloud.Router, s *cloud.Service[state]) {
zip.Get(v1, "/tools", o.listTools)
zip.Get(v1, "/tools/activation", o.getActivation)
zip.Put(v1, "/tools/activation", o.putActivation)
v1.Post("/tools/mcp", cloud.Handle(s, mcp))
zip.Post(v1, "/tools/call", o.callTool)
// The separately-listed registries (see registries.go): skills and mcp are
// Source views of the SAME registry; plugins is the mounted-subsystem
@@ -175,7 +170,6 @@ func routes(app cloud.Router, s *cloud.Service[state]) {
zip.Post(v1, "/skills", o.putSkill, zip.WithStatus(http.StatusCreated))
zip.Get(v1, "/skills/authored", o.listAuthoredSkills)
zip.Delete(v1, "/skills/:id", o.deleteSkill)
zip.Get(v1, "/mcp", o.listMCPTools)
zip.Get(v1, "/plugins", o.listPlugins)
// The builder (pluginbuild.go). /v1/plugins lists what this deployment
@@ -185,8 +179,9 @@ func routes(app cloud.Router, s *cloud.Service[state]) {
zip.Get(v1, "/plugins/authored", o.listAuthoredPlugins)
zip.Delete(v1, "/plugins/authored/:id", o.deleteAuthoredPlugin)
// The external MCP server registry lives with /v1/mcp, not under /v1/tools:
// a server is a record an org creates, not a tool the registry enumerates.
// The external MCP server registry: a server is a record an org creates, not a
// tool the registry enumerates. /v1/mcp itself belongs to the fleet's ONE agent
// door (the host serves it), so this owns the deeper /v1/mcp/servers alone.
zip.Get(v1, "/mcp/servers", o.listServers)
zip.Post(v1, "/mcp/servers", o.createServer, zip.WithStatus(http.StatusCreated))
zip.Delete(v1, "/mcp/servers/:id", o.deleteServer)
+26
View File
@@ -108,6 +108,32 @@ func callerOf(ctx context.Context) string {
return ""
}
// principalOf is the whole validated caller a DISPATCH needs — org, project,
// user, owner, admin-ness and the credential headers a provider replays — which
// is strictly more than tenantOf's org. It comes off the REQUEST because the
// credential set does, and it fails closed off the HTTP path for the same reason
// tenantOf does: no request means no attested caller, and a dispatch with no
// caller has no scope to be confined to.
func principalOf(ctx context.Context) (Principal, error) {
c, ok := cloud.Request(ctx)
if !ok {
return Principal{}, zip.ErrForbidden("a validated principal is required")
}
p, ok := PrincipalFrom(c)
if !ok {
return Principal{}, zip.ErrForbidden("a validated principal is required")
}
return p, nil
}
// meter records the one orchestration unit a tool call bills — meterUnit with the
// request resolved off the context. Off the HTTP path there is nothing to bill.
func (o toolOps) meter(ctx context.Context) {
if c, ok := cloud.Request(ctx); ok {
meterUnit(o.s, c)
}
}
// audit appends one audit record from a typed op — audrecordAction with the
// request resolved off the context. Off the HTTP path there is no actor, no
// method, no path and no source IP, so it records NOTHING rather than an
+30 -106
View File
@@ -11,7 +11,7 @@ import (
)
// This file makes the tool plane's typed partition a GATE instead of a paragraph.
// "14 of 16" is prose, and prose cannot fail: a route added tomorrow as a raw
// "all but one" is prose, and prose cannot fail: a route added tomorrow as a raw
// func(*zip.Ctx) error would leave the claim standing and the route invisible to
// every projection — no schema, no description, no MCP tool, no CLI command, no
// SDK method. Here the claim is a test, so the route that falsifies it says so.
@@ -20,30 +20,15 @@ import (
// ops, each with the wire fact that keeps it raw. The address is written the way
// the DOCUMENT writes it, which is the identity every projection keys on.
//
// Both entries are wire-bound and both were re-read against the PINNED zip
// (v1.18.11), not inherited as prose from an older pass.
// It holds ONE entry now. The other was the hand-rolled MCP JSON-RPC surface at
// POST /v1/tools/mcp, and it is gone rather than typed: the fleet serves ONE MCP
// door, on the host, and this plane reaches it as a typed op (POST /v1/tools/call)
// like everything else. A JSON-RPC envelope is a transport, and there is now
// exactly one place in the fleet that speaks it.
//
// The remaining entry is wire-bound and was re-read against the PINNED zip
// (v1.18.12), not inherited as prose from an older pass.
var untypedByDesign = map[string]string{
// The unified MCP JSON-RPC surface. Two facts, either one sufficient.
//
// 1. IT IS DELIBERATELY BODY-TOLERANT. A body that is not JSON answers HTTP
// 200 carrying the JSON-RPC parse error (-32700) — the MCP convention, and
// what TestMCPToleratesAMalformedBody below pins. op.invoke decodes the
// body BEFORE the handler runs and returns ErrBadRequest on any failure
// (v1.18.11 typed.go:240-243), so typing this route turns every one of
// those 200s into a 400 that no MCP client expects.
// 2. ITS REQUEST AND RESPONSE ARE ENVELOPES WHOSE SHAPE DEPENDS ON `method`.
// params is `any` — tools/call reads name+arguments, initialize and ping
// read nothing — and the result object differs per method. One In and one
// Out cannot describe that without publishing a shape the wire does not
// carry.
//
// Neither is closable inside cloud: zip has no body-TOLERANT op and no
// per-method response vocabulary. Note this route is not invisible to agents
// for it — it IS the MCP surface, reached as JSON-RPC rather than as a tool.
"POST /v1/tools/mcp": "deliberately body-tolerant (a malformed body is HTTP 200 + JSON-RPC -32700, " +
"which op.invoke's unconditional 400 on an unparseable body cannot express) and its request/response " +
"are JSON-RPC envelopes whose shape depends on `method`.",
// The plugin builder. A FAILED build answers 422 carrying the build
// DIAGNOSTICS as a domain body — the bundler's error, the source that failed,
// and whether the model wrote it — which is the only thing that lets a caller
@@ -124,10 +109,10 @@ func TestEveryRouteIsTypedOrNamed(t *testing.T) {
t.Errorf("typed(%d) + named(%d) = %d, served = %d — the ledgers must partition the surface",
len(typed), len(untypedByDesign), got, want)
}
// The MEASURED partition, so "14 of 16" in the docs cannot drift from the
// The MEASURED partition, so "all but one" in the docs cannot drift from the
// binary. Changing these numbers is a deliberate edit, which is the point.
if len(served) != 16 || len(typed) != 14 {
t.Errorf("served = %d (want 16), typed = %d (want 14)", len(served), len(typed))
if len(served) != 15 || len(typed) != 14 {
t.Errorf("served = %d (want 15), typed = %d (want 14)", len(served), len(typed))
}
}
@@ -190,24 +175,9 @@ func TestEveryPublishedFieldIsDescribed(t *testing.T) {
}
}
// ── the wire the two refusals protect ───────────────────────────────────────────
// ── the wire the one refusal protects ───────────────────────────────────────────
// TestMCPToleratesAMalformedBody is the pin under the first untypedByDesign entry.
// A body that is not JSON is HTTP 200 carrying JSON-RPC -32700, because that is
// what an MCP client parses. Type this route and op.invoke answers 400 before the
// handler runs — this test is what turns that from a regression into a failure.
func TestMCPToleratesAMalformedBody(t *testing.T) {
app := newApp(t, nil)
r := rpc(t, app, "acme", `{not json`)
if r.Code != 200 {
t.Fatalf("malformed MCP body = %d (%s), want 200 — the JSON-RPC parse error is a 200 body", r.Code, r.Body)
}
if !strings.Contains(string(r.Body), "-32700") {
t.Errorf("malformed MCP body must answer JSON-RPC -32700, got %s", r.Body)
}
}
// TestBuildFailureCarriesItsDiagnostics is the pin under the second entry. A build
// TestBuildFailureCarriesItsDiagnostics is the pin under the single entry. A build
// that does not compile answers 422 with the bundler's detail, the source that
// failed and whether a model wrote it — the only thing that lets a caller fix the
// plugin, and a shape zip's flat HTTPError has nowhere to put.
@@ -230,15 +200,14 @@ func TestBuildFailureCarriesItsDiagnostics(t *testing.T) {
}
}
// TestTheUntypedRoutesStillDeclareTheirBodies is the OTHER half of a refusal.
// TestTheUntypedRouteStillDeclaresItsBody is the OTHER half of a refusal.
// Staying out of zip's registry costs prose, an MCP tool, a CLI command and a
// typed SDK method — it must not also cost the SHAPE. Both of these rendered as
// an operationId and a tag and nothing else, which is precisely what a route
// taking no input and returning none publishes, so no consumer of the document
// could tell "takes a JSON-RPC envelope" from "takes nothing". openapi.Register
// (tools.go's init) states the halves that ARE statable; this is the gate that
// they stay stated.
func TestTheUntypedRoutesStillDeclareTheirBodies(t *testing.T) {
// typed SDK method — it must not also cost the SHAPE. This route rendered as an
// operationId and a tag and nothing else, which is precisely what a route taking
// no input and returning none publishes, so no consumer of the document could
// tell "takes a plugin source" from "takes nothing". openapi.Register (tools.go's
// init) states the half that IS statable; this is the gate that it stays stated.
func TestTheUntypedRouteStillDeclaresItsBody(t *testing.T) {
app := newApp(t, nil)
doc, err := openapi.Spec(app, openapi.Info{Title: "tools", Version: "v1"})
if err != nil {
@@ -253,7 +222,6 @@ func TestTheUntypedRoutesStillDeclareTheirBodies(t *testing.T) {
} `json:"schema"`
}
for _, c := range []struct{ path, req, resp string }{
{"/v1/tools/mcp", "mcpRequest", "mcpResponse"},
{"/v1/plugins/build", "buildRequest", "buildOut"},
} {
raw, err := json.Marshal(doc.Paths[c.path]["post"])
@@ -306,53 +274,10 @@ func TestTheUntypedRoutesStillDeclareTheirBodies(t *testing.T) {
}
}
// TestMCPEnvelopeIsByteIdentical pins the rename that made the MCP response
// declarable. The envelope was a Go map, which encoding/json writes in SORTED KEY
// order; mcpResponse's fields are alphabetical for exactly that reason. Asserting
// the marshalled BYTES is what proves naming the shape did not move the wire —
// a status-code test would pass either way.
func TestMCPEnvelopeIsByteIdentical(t *testing.T) {
for _, c := range []struct {
name string
got, maply any
}{
{
"result",
rpcResult(7, map[string]any{"tools": []any{}}),
map[string]any{"jsonrpc": "2.0", "id": 7, "result": map[string]any{"tools": []any{}}},
},
{
"result with a null id",
rpcResult(nil, map[string]any{}),
map[string]any{"jsonrpc": "2.0", "id": nil, "result": map[string]any{}},
},
{
"error",
rpcError("abc", -32601, "method not found: nope"),
map[string]any{"jsonrpc": "2.0", "id": "abc", "error": map[string]any{"code": -32601, "message": "method not found: nope"}},
},
{
"parse error, no id to echo",
rpcError(nil, -32700, "parse error: x"),
map[string]any{"jsonrpc": "2.0", "id": nil, "error": map[string]any{"code": -32700, "message": "parse error: x"}},
},
} {
got, err := json.Marshal(c.got)
if err != nil {
t.Fatalf("%s: marshal struct: %v", c.name, err)
}
want, err := json.Marshal(c.maply)
if err != nil {
t.Fatalf("%s: marshal map: %v", c.name, err)
}
if string(got) != string(want) {
t.Errorf("%s:\n got %s\nwant %s", c.name, got, want)
}
}
}
// TestBuildReceiptIsByteIdentical is the same pin for the builder's 201 body,
// which was also a map. buildOut's fields are alphabetical so the bytes match.
// TestBuildReceiptIsByteIdentical pins the builder's 201 body, which was a Go map
// encoding/json writes a map in SORTED KEY order, so buildOut's fields are
// alphabetical. Asserting the marshalled BYTES is what proves naming the shape did
// not move the wire; a status-code test would pass either way.
func TestBuildReceiptIsByteIdentical(t *testing.T) {
stored := AuthoredPlugin{ID: "p1", Org: "acme", Name: "hello", Source: "export default {}", CreatedAt: 42}
got, err := json.Marshal(buildOut{Bytes: 12, Generated: true, Plugin: stored})
@@ -368,7 +293,7 @@ func TestBuildReceiptIsByteIdentical(t *testing.T) {
}
}
// ── the wire the fourteen typed ops kept ────────────────────────────────────────
// ── the wire the typed ops kept ─────────────────────────────────────────────────
// TestActivatedFilterIsTheLiteralTrue pins why the three filter fields are STRINGS
// and not bools. These routes have always compared the raw query value to "true",
@@ -379,10 +304,9 @@ func TestActivatedFilterIsTheLiteralTrue(t *testing.T) {
app := newApp(t, nil)
std.Register(&fakeProvider{src: SourceConnector, tools: []Tool{tool("acme_hello", SourceConnector)}})
// Count the ONE unactivated tool the fake source contributes. The listing also
// carries the builtin "full-cloud-control" tools this very mount produces, so
// the assertion is on the tool under test and not on a total that grows with
// every route added here.
// Count the ONE unactivated tool the fake source contributes — the assertion is
// on the tool under test and not on a total, so a source registered by some
// other part of this mount cannot move it.
count := func(path string) int {
r := do(t, app, http.MethodGet, path, "acme", nil)
if r.Code != 200 {
@@ -505,7 +429,7 @@ func TestTypedOpsFailClosedWithoutAPrincipal(t *testing.T) {
{http.MethodPost, "/v1/skills", map[string]any{"name": "x", "content": "y"}},
{http.MethodGet, "/v1/skills/authored", nil},
{http.MethodDelete, "/v1/skills/x", nil},
{http.MethodGet, "/v1/mcp", nil},
{http.MethodPost, "/v1/tools/call", map[string]any{"name": "x", "arguments": map[string]any{}}},
{http.MethodGet, "/v1/mcp/servers", nil},
{http.MethodPost, "/v1/mcp/servers", map[string]any{"name": "x", "url": "https://mcp.example.com"}},
{http.MethodDelete, "/v1/mcp/servers/x", nil},
+14 -22
View File
@@ -29,24 +29,6 @@ func init() {
"skillRef.id": "ID is the skill to remove, from the path. It is the skill's name.",
},
})
zip.Describe("GET /v1/mcp", zip.Doc{
Description: "ListMCPTools lists the tools reachable on the external MCP servers the caller's\norg has registered, with each one's activation flag. Every name is prefixed by\nthe server id it came from, which is what keeps two servers offering \"search\"\nfrom colliding. It is GET /v1/tools narrowed to one source, so an external tool\nstill cannot shadow a native one — mcp is the lowest-precedence source.",
Fields: map[string]string{
"Price.amountCents": "AmountCents is what ONE call costs, in minor units of Currency.",
"Price.currency": "Currency is the ISO 4217 code, e.g. \"USD\". Empty means USD.",
"Price.recipient": "Recipient is the payout wallet ref the marketplace seller is paid at.",
"Tool.activated": "Activated is filled by the registry from the activation store for the\nrequesting (org,project); providers leave it zero. An unactivated tool is\ndiscoverable but refused 403 at dispatch.",
"Tool.description": "Description is the prose a model reads to decide whether to call the tool.",
"Tool.dispatchable": "Dispatchable is whether the tool can be CALLED. False for a listing-only\nentry: a skill is activated and attached to an agent, never called.",
"Tool.inputSchema": "Schema is the JSON Schema of the call arguments — the MCP inputSchema.\nAbsent for a tool that takes none.",
"Tool.name": "Name is the tool's id in the flat, fleet-wide tool namespace — the value a\ntools/call passes. Unique across sources: a collision is resolved by source\nprecedence before the caller ever sees it.",
"Tool.price": "Price is what a call costs and who is paid, absent for a free tool.\nEnforcement is the x402 settlement seam; this is the declaration.",
"Tool.source": "Source is where the tool comes from: builtin, connector, function,\nzap-service, agent, skill or mcp.",
"sourceQuery.activated": "Activated keeps only the tools activated for the caller's org and project,\nand only when it is exactly the string \"true\".",
"sourceToolList.source": "Source is the source these tools came from.",
"sourceToolList.tools": "Tools is the caller's tools from that source. Never null.",
},
})
zip.Describe("GET /v1/mcp/servers", zip.Doc{
Description: "ListServers lists the external MCP servers the caller's org has registered.\nEach record carries the URL and the name of the header its credential is\ninjected into; the credential VALUE lives only in KMS and is never returned,\nso hasSecret is the whole of what this surface says about it.",
Fields: map[string]string{
@@ -94,7 +76,7 @@ func init() {
"Tool.inputSchema": "Schema is the JSON Schema of the call arguments — the MCP inputSchema.\nAbsent for a tool that takes none.",
"Tool.name": "Name is the tool's id in the flat, fleet-wide tool namespace — the value a\ntools/call passes. Unique across sources: a collision is resolved by source\nprecedence before the caller ever sees it.",
"Tool.price": "Price is what a call costs and who is paid, absent for a free tool.\nEnforcement is the x402 settlement seam; this is the declaration.",
"Tool.source": "Source is where the tool comes from: builtin, connector, function,\nzap-service, agent, skill or mcp.",
"Tool.source": "Source is where the tool comes from: connector, function, zap-service,\nagent, skill or mcp.",
"sourceQuery.activated": "Activated keeps only the tools activated for the caller's org and project,\nand only when it is exactly the string \"true\".",
"sourceToolList.source": "Source is the source these tools came from.",
"sourceToolList.tools": "Tools is the caller's tools from that source. Never null.",
@@ -113,7 +95,7 @@ func init() {
},
})
zip.Describe("GET /v1/tools", zip.Doc{
Description: "ListTools lists every tool the caller's org and project can reach, from every\nsource, each flagged with whether it is activated. This is the discovery\nsurface: one flat set of names spanning builtin cloud controls, connector\nactions, user functions, zap-service routes, agents, skills and the org's own\nexternal MCP servers, deduplicated by name so the highest-precedence source\nwins a collision. It lists; it does not call — dispatch is the MCP endpoint.",
Description: "ListTools lists every tool the caller's org and project can reach, from every\nsource, each flagged with whether it is activated. This is the discovery\nsurface: one flat set of names spanning connector actions, user functions,\nzap-service routes, agents, skills and the org's own external MCP servers,\ndeduplicated by name so the highest-precedence source wins a collision. It\nlists; it does not call — dispatch is POST /v1/tools/call.",
Fields: map[string]string{
"Price.amountCents": "AmountCents is what ONE call costs, in minor units of Currency.",
"Price.currency": "Currency is the ISO 4217 code, e.g. \"USD\". Empty means USD.",
@@ -124,10 +106,10 @@ func init() {
"Tool.inputSchema": "Schema is the JSON Schema of the call arguments — the MCP inputSchema.\nAbsent for a tool that takes none.",
"Tool.name": "Name is the tool's id in the flat, fleet-wide tool namespace — the value a\ntools/call passes. Unique across sources: a collision is resolved by source\nprecedence before the caller ever sees it.",
"Tool.price": "Price is what a call costs and who is paid, absent for a free tool.\nEnforcement is the x402 settlement seam; this is the declaration.",
"Tool.source": "Source is where the tool comes from: builtin, connector, function,\nzap-service, agent, skill or mcp.",
"Tool.source": "Source is where the tool comes from: connector, function, zap-service,\nagent, skill or mcp.",
"toolList.tools": "Tools is every tool the caller may see, deduplicated by name with source\nprecedence applied.",
"toolQuery.activated": "Activated keeps only the tools activated for the caller's org and project,\nand only when it is exactly the string \"true\".",
"toolQuery.source": "Source keeps only tools from one source — builtin, connector, function,\nzap-service, agent, skill or mcp. Empty keeps every source.",
"toolQuery.source": "Source keeps only tools from one source — connector, function, zap-service,\nagent, skill or mcp. Empty keeps every source.",
},
})
zip.Describe("GET /v1/tools/activation", zip.Doc{
@@ -169,6 +151,16 @@ func init() {
},
Example: json.RawMessage(`{"name":"triage","description":"how we triage","content":"# Triage\n…"}`),
})
zip.Describe("POST /v1/tools/call", zip.Doc{
Description: "CallTool runs one of the caller's activated tools and answers with its output.\n\nThis is the door onto the tool plane's DYNAMIC half — the half no build-time\ncatalogue can hold, because it is per-tenant: an org's connected connector\nactions, its authored skills, its agents and functions, and the tools of every\nexternal MCP server it registered. A tool's existence, its price and its\nactivation are all rows, not code, so they cannot be known until the caller is.\n\nOne policy, the registry's: resolve by precedence, refuse an unactivated tool\n403, settle a priced one through the x402 seam or fail closed 402, then\ndispatch to the winning source bound to the caller's own (org, project). One\nmetered unit, one audit record. A caller can only ever dispatch its own tools.\n\nDiscovery is GET /v1/tools — ?activated=true for the callable set.",
Fields: map[string]string{
"toolCall.arguments": "Arguments is the tool's own input object, passed through verbatim to\nwhichever source owns it.",
"toolCall.name": "Name is the tool to run, exactly as GET /v1/tools reports it.",
"toolResult.name": "Name is the tool that ran.",
"toolResult.result": "Result is the tool's own output, verbatim — its shape is the tool's, not\nthis plane's.",
},
Example: json.RawMessage(`{"name":"slack_post_message","arguments":{"channel":"#general","text":"hi"}}`),
})
zip.Describe("PUT /v1/tools/activation", zip.Doc{
Description: "PutActivation switches tools on and off for the caller's org and project, and\nanswers with the resulting activated set. It is the ONE write path that turns\nskills, plugins and connectors into callable tools — an unactivated tool is\nlisted by discovery but refused 403 at dispatch. Activate is applied before\nDeactivate, so a name in both lists ends up off. More than 256 toggles in one\nrequest is refused 413.",
Fields: map[string]string{
+6 -7
View File
@@ -129,13 +129,12 @@ func TestOpenAPICarriesTheSurface(t *testing.T) {
// TestMCPPublishesTheSurface pins the second derived projection: the same ops,
// published as tools under the same names, with their In schema.
//
// The tool DESCRIPTIONS are empty, and that is a gap in zip, not here: mcp.go
// emits op.Summary verbatim and never consults the zipdoc extraction the OpenAPI
// builder falls back to (openapi.go docFor). The prose is already in the registry
// — zipdoc_gen.go put it there — so when that projection learns to read it, every
// op here gains its description with no change on this side. Passing
// WithSummary("…") to close it today would write the same sentence twice, which
// is the one thing the doc pass exists to prevent.
// The tool descriptions carry the zipdoc prose now — mcpToolOf reads the same
// docFor extraction the OpenAPI builder does (zip mcp.go), so one doc comment
// serves the document, the CLI help and the tool list. Nothing here passes
// WithSummary to say the same sentence twice. That prose is what the FLEET's one
// MCP door hands a model: this app's catalogue is plugin/visor/mcp.json, and
// manifest/mcp_test.go fails a tool whose description is empty.
func TestMCPPublishesTheSurface(t *testing.T) {
tools := body(t, projectionApp(t), "POST", "/mcp",
`{"jsonrpc":"2.0","id":1,"method":"tools/list"}`)
+16 -1
View File
@@ -44,6 +44,7 @@ import (
"github.com/hanzoai/cloud/credz/launch"
"github.com/hanzoai/cloud/manifest"
"github.com/hanzoai/cloud/plugin"
"github.com/hanzoai/cloud/webui"
"github.com/zap-proto/zip"
)
@@ -93,7 +94,17 @@ func forward(kv map[string]string) {
}
func run(addr, zapAddr, enable string) error {
app := zip.New(zip.Config{AppName: "cloud"})
// THE FLEET'S ONE AGENT DOOR, at POST /v1/mcp. zip serves it: initialize, ping,
// tools/list and tools/call are its handleMCP, and the tool list is the union
// of every mounted plugin's build-time catalogue (mount below), rendered once
// as bytes. So tools/list — the method an MCP client calls constantly — is a
// memcpy and starts NO child; only a tools/call wakes one, the single plugin
// that owns the named tool, over ZAP on its private socket.
//
// The host is the only process that can own it. MCPTools() is in-process, so a
// plugin cannot enumerate a lazy sibling, and a plugin-hosted door would cost
// its own wake on the very first list.
app := zip.New(zip.Config{AppName: "cloud", MCP: zip.MCPConfig{Path: "/v1/mcp"}})
// Mint this host's child-signing secret and take the KMS root key OUT of the
// host's own environment — both BEFORE the first Load spawns an eager child.
@@ -231,6 +242,10 @@ func mount(app *zip.App, a manifest.App, eager bool, secret, rootKey string, abs
}
p := a.Plugin()
p.Lazy = !eager
// This app's MCP tools, from the artifact its own binary wrote when it was
// built. Given them, zip serves this app's tools on the host's door and
// forwards a tools/call to this app alone — without ever running it to ask.
p.Tools = plugin.Tools(a.Name)
// Per-plugin, on the plugin's OWN Env, which zip appends to that ONE child's
// environment: a scoped token for every child, and — for the broker alone —
// the launch secret and the root key. A token or key placed in the host's
+151
View File
@@ -0,0 +1,151 @@
package cloud
// `<binary> describe <dir>` — any cloud binary projects itself instead of serving.
//
// This is the ONE producer of a per-app artifact. An app's projections are not
// sliced out of the fleet's by prefix (that would make the fleet the source and
// the app a derivative, exactly backwards); they are generated from the app's OWN
// live router by the SAME openapi.FleetSpec and the SAME zip.App.MCPTools the
// whole fleet is generated from, over an app with only that subsystem mounted.
// Compose upward, never carve downward — see openapi/weave.go for the other half.
//
// TWO projections, ONE mount, one instant, one registry: openapi.json (what the
// app's addresses are) and mcp.json (what its typed ops are as MCP tools). They
// are written together and cannot be generated apart, so a tool cannot exist
// without its op, and a schema cannot go stale on one surface while the other
// moves. That is the whole honesty argument for the door: the reverse direction —
// "every typed op is a tool" — is not tested, it is unfalsifiable by construction.
//
// It lives on Serve because Serve is the single entry every app binary shares:
// plugin/<app>/main.go is generated as one cloud.Serve call, so putting the mode
// here gives every one of them the target at a cost of zero per-app code. A binary
// that mounts its own app (plugin/o11y) calls Describe directly.
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"github.com/hanzoai/cloud/openapi"
"github.com/zap-proto/zip"
)
// describeArg is the argv word that switches a binary from serving to describing.
const describeArg = "describe"
// SpecFile and ToolsFile are the two artifacts a describe run writes into the
// app's own plugin/<app> directory. Named here because the host EMBEDS ToolsFile
// (plugin/embed.go) and the weave READS SpecFile — one name each, so a rename
// cannot leave a reader looking for a file no writer produces.
const (
SpecFile = "openapi.json"
ToolsFile = "mcp.json"
)
// DescribeRequested reports whether argv asks this binary to describe itself, and
// the DIRECTORY it named: `<binary> describe <dir>`. Read before any flag parsing
// — the mode is a mode, not an option.
//
// A DIRECTORY and not stdout, and the argument is required. There are two
// artifacts, so there is no single stream to write; and a subsystem's own
// dependencies write to stdout anyway (hanzoai/commerce prints a sqlite-vec
// warning and GORM debug lines at mount), which a `> file` redirect splices into
// the front of the document and turns into 71KB of invalid JSON. A writer whose
// output an unrelated library can corrupt is not a writer.
func DescribeRequested() (string, bool) {
if len(os.Args) > 1 && os.Args[1] == describeArg {
if len(os.Args) > 2 {
return os.Args[2], true
}
return "", true
}
return "", false
}
// SpecConfig is the deployment the PUBLISHED artifacts describe, and the whole
// of it — zero values everywhere else, on purpose. The returned func removes the
// throwaway data dir.
//
// A published spec must be a function of the code alone. Config decides routes:
// clients/kms registers its secret routes only when a master key resolved, and
// several subsystems gate on brand. If this read the environment, the artifacts
// two developers generated from one commit would differ by whichever CLOUD_*
// variables their shells carried, and the golden would flap in CI for a reason
// no diff could explain. So it reads nothing.
//
// The data dir is a throwaway and is created HERE rather than taken as an
// argument, because mounting opens real stores: the default is /var/lib/cloud,
// and a caller that forgot to override it would either migrate a live store or
// (as cmd/o11y did) fail on it.
func SpecConfig() (*Config, func(), error) {
dir, err := os.MkdirTemp("", "openapi-spec-*")
if err != nil {
return nil, nil, err
}
return &Config{Brand: DefaultBrand, Domain: "api.hanzo.ai", DataDir: dir},
func() { os.RemoveAll(dir) }, nil
}
// Describe writes app's TWO projections into dir: the OpenAPI document and the
// MCP tool catalogue, from the one live router, in one pass.
//
// JSON for both, because JSON is what they ARE — the document is the same bytes
// served at /v1/openapi.json and dropped into hanzoai/openapi, and the catalogue
// is the same bytes the host hands zip as Plugin.Tools. Encoding to YAML would
// put a yaml library in the graph of every app binary to write a file only the
// weave reads. Indented so a subset reviews as a diff.
//
// Each artifact is rendered whole before its file is touched, so a projection
// failure leaves the previous one intact rather than truncating it into an app
// that appears to serve nothing. mcp.json is written for EVERY app, including the
// ones with no typed ops yet (an empty array), so the host's embed pattern is
// always satisfiable and "this app got its first typed op" shows as a diff in a
// file that already exists rather than as a new one nobody reviews.
func Describe(dir string, app *zip.App) error {
if dir == "" {
return fmt.Errorf("usage: %s %s <dir>", filepath.Base(os.Args[0]), describeArg)
}
doc, err := openapi.FleetSpec(app)
if err != nil {
return fmt.Errorf("openapi: %w", err)
}
spec, err := json.MarshalIndent(doc, "", " ")
if err != nil {
return err
}
// MCPTools is already sorted by name (zip mcp.go), so this artifact is a
// function of the op set and not of registration order — an edit that moved
// nothing a client can see produces no diff.
tools, err := json.MarshalIndent(app.MCPTools(), "", " ")
if err != nil {
return fmt.Errorf("mcp: %w", err)
}
if err := os.WriteFile(filepath.Join(dir, SpecFile), append(spec, '\n'), 0o644); err != nil {
return err
}
return os.WriteFile(filepath.Join(dir, ToolsFile), append(tools, '\n'), 0o644)
}
// describe mounts specs into a throwaway app and writes its projections.
//
// Enablement is cfg's default, NOT the forced single-service list Serve applies:
// this is the fleet's document, and a STAGED subsystem (config.go's
// a deployment does not name) is linked but inert until it does. Describing
// one here would publish routes api.hanzo.ai does not serve, and would make the
// woven document disagree with the fully-mounted golden — which is the equality
// the composition proof rests on.
func describe(specs []Plugin, dir string) error {
cfg, done, err := SpecConfig()
if err != nil {
return err
}
defer done()
deps := BuildDeps(cfg)
app := zip.New(zip.Config{Logger: deps.Logger, DisableStartupMessage: true})
if err := MountAll(app, specs, cfg, deps); err != nil {
return err
}
return Describe(dir, app)
}
+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env bash
# THE ONE MCP DOOR, end to end, against a real host and real plugin processes.
#
# It proves the two properties the design rests on, by MEASURING them rather than
# by reading code:
#
# 1. tools/list costs ZERO process wakes. The host answers from the plugins'
# build-time catalogues (plugin/<app>/mcp.json, embedded via plugin/embed.go),
# so the child process count before and after must be identical.
# 2. tools/call wakes exactly ONE child — the plugin that owns the named tool —
# over ZAP on its private unix socket, and returns that plugin's own answer.
#
# It also fails when a listed tool has an EMPTY description. A model pays context
# for every tool it is shown and cannot choose one that says nothing; that exact
# bug shipped here once (zipdoc blind to group prefixes), so it is asserted.
#
# usage: make cloud && make -f mk/fleet.mk describe-apps # host + plugins into ./bin
# e2e/mcp-door.sh
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
BIN="$ROOT/bin"
PORT="${PORT:-18080}"
# EMPTY mounts the WHOLE fleet, which is the strongest form of the measurement:
# 112 plugins mounted, tools/list answered, child count unchanged. A named subset
# runs faster; every child pulls its data-plane key from the broker, so any subset
# must include it (cmd/cloud refuses one that omits it).
ENABLE="${ENABLE-}"
export CLOUD_KMS_MASTER_KEY_REF="${CLOUD_KMS_MASTER_KEY_REF:-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=}"
export CLOUD_DATA_DIR="${CLOUD_DATA_DIR:-$(mktemp -d)}"
[ -x "$BIN/cloud" ] || { echo "no $BIN/cloud — run: make cloud"; exit 1; }
"$BIN/cloud" --listen "127.0.0.1:$PORT" --zap "127.0.0.1:0" ${ENABLE:+--enable "$ENABLE"} \
>"$CLOUD_DATA_DIR/host.log" 2>&1 &
HOST=$!
trap 'kill -TERM $HOST 2>/dev/null || true; wait $HOST 2>/dev/null || true' EXIT
for _ in $(seq 1 300); do
curl -sf "http://127.0.0.1:$PORT/healthz" >/dev/null 2>&1 && break
sleep 0.2
done
curl -sf "http://127.0.0.1:$PORT/healthz" >/dev/null || { echo "host never came up:"; cat "$CLOUD_DATA_DIR/host.log"; exit 1; }
# children counts only the plugins THIS host spawned.
children() { pgrep -P "$HOST" 2>/dev/null | wc -l | tr -d ' '; }
# The caller's own credential rides through: the host forwards the inbound request
# verbatim, and the OWNING plugin's cloud.Serve chain re-derives identity from it.
rpc() {
curl -s -X POST "http://127.0.0.1:$PORT/v1/mcp" -H 'Content-Type: application/json' \
${AUTH:+-H "Authorization: $AUTH"} -d "$1"
}
echo "== the door is the host's =="
rpc '{"jsonrpc":"2.0","id":0,"method":"initialize"}' | tee "$CLOUD_DATA_DIR/init.json"; echo
BEFORE=$(children)
echo "== tools/list =="
rpc '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' > "$CLOUD_DATA_DIR/list.json"
AFTER=$(children)
COUNT=$(python3 -c 'import json,sys;print(len(json.load(open(sys.argv[1]))["result"]["tools"]))' "$CLOUD_DATA_DIR/list.json")
echo "tools listed: $COUNT children before=$BEFORE after=$AFTER"
[ "$BEFORE" = "$AFTER" ] || { echo "FAIL: tools/list woke $((AFTER-BEFORE)) child process(es); it must wake ZERO"; exit 1; }
[ "$COUNT" -gt 0 ] || { echo "FAIL: the door lists no tools"; exit 1; }
python3 - "$CLOUD_DATA_DIR/list.json" <<'PY'
import json, sys
tools = json.load(open(sys.argv[1]))["result"]["tools"]
bare = [t["name"] for t in tools if not (t.get("description") or "").strip()]
if bare:
print("FAIL: tools listed with an EMPTY description:", ", ".join(bare[:10])); sys.exit(1)
noschema = [t["name"] for t in tools if not t.get("inputSchema")]
if noschema:
print("FAIL: tools listed with no inputSchema:", ", ".join(noschema[:10])); sys.exit(1)
print("every listed tool carries prose and a schema")
PY
echo
echo "== one tool, with the doc comment its handler carries =="
# A READ by default: the point is to show the owner answering, not to mutate.
TOOL="${TOOL:-$(python3 -c '
import json,sys
tools=json.load(open(sys.argv[1]))["result"]["tools"]
reads=[t["name"] for t in tools if t["name"].startswith("get_")]
print((reads or [t["name"] for t in tools])[0])' "$CLOUD_DATA_DIR/list.json")}"
python3 - "$CLOUD_DATA_DIR/list.json" "$TOOL" <<'PY'
import json, sys
for t in json.load(open(sys.argv[1]))["result"]["tools"]:
if t["name"] == sys.argv[2]:
print(json.dumps(t, indent=2)[:1600]); break
else:
print("FAIL: %s is not on the door" % sys.argv[2]); sys.exit(1)
PY
echo
echo "== tools/call =="
BEFORE=$(children)
ARGS="${ARGS:-{\}}"
rpc "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"$TOOL\",\"arguments\":$ARGS}}" | tee "$CLOUD_DATA_DIR/call.json"; echo
AFTER=$(children)
echo "children before=$BEFORE after=$AFTER (a cold owner wakes exactly one)"
[ $((AFTER-BEFORE)) -le 1 ] || { echo "FAIL: tools/call woke $((AFTER-BEFORE)) children; it must wake at most ONE"; exit 1; }
echo
echo "OK — one door, zero wakes on list, one wake on call."
+1 -1
View File
@@ -44,7 +44,7 @@ require (
github.com/zap-proto/fiber/v3 v3.2.1
github.com/zap-proto/go v1.3.0
github.com/zap-proto/md v0.1.0
github.com/zap-proto/zip v1.18.11
github.com/zap-proto/zip v1.18.12
go.opentelemetry.io/collector/component v1.54.0
go.opentelemetry.io/collector/confmap v1.54.0
go.opentelemetry.io/collector/confmap/provider/envprovider v1.50.0
+2
View File
@@ -2052,6 +2052,8 @@ github.com/zap-proto/zap2pb v0.2.0 h1:sos6HnayhGMGLRO54px1InzimDzTZ2o5TSMEatYBjz
github.com/zap-proto/zap2pb v0.2.0/go.mod h1:wD97Z2VTPabDq/4AMNL++PWnQ0YwEtajiuNkLGg3/18=
github.com/zap-proto/zip v1.18.11 h1:Y73qJvtM+qLqo9B663gGenVYlHPPXM7UL33Jfk+iR64=
github.com/zap-proto/zip v1.18.11/go.mod h1:EKMmUX9wCPvpkhpMBQRqa17YVXNXRYEjj7X+65Y+J9E=
github.com/zap-proto/zip v1.18.12 h1:8cWSszm6dPkBlwjtGjDv+bTqpGqb70P4iRl3JBnGymQ=
github.com/zap-proto/zip v1.18.12/go.mod h1:EKMmUX9wCPvpkhpMBQRqa17YVXNXRYEjj7X+65Y+J9E=
github.com/zeebo/assert v1.3.1 h1:vukIABvugfNMZMQO1ABsyQDJDTVQbn+LWSMy1ol1h6A=
github.com/zeebo/assert v1.3.1/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI=
+2 -2
View File
@@ -125,13 +125,13 @@ test:
set -e
export GOPRIVATE='github.com/hanzoai/*' GOWORK=off
# ONE gate definition, called here rather than restated: mk/fleet.mk's
# openapi-check regenerates every subset AND weaves the golden, then fails
# surface-check regenerates every subset AND weaves the golden, then fails
# on any porcelain change. This step used to regenerate the subsets only,
# so openapi.yaml — the file the SDK repos actually pull — was never checked
# against source by anything: openapi-composed above compares it to the
# subsets, and both are derived. That is how plugin/ingress lost eight paths
# with every gate green.
make -f mk/fleet.mk openapi-check
make -f mk/fleet.mk surface-check
- name: go-unit
run: |
set -e
+1 -1
View File
@@ -158,7 +158,7 @@ var Apps = []App{
{Name: "admission", Prefixes: []string{"/v1/flags/waitlist"}},
{Name: "tasks", Prefixes: []string{"/tasks", "/v1/tasks"}},
{Name: "automations", Prefixes: []string{"/v1/automations"}},
{Name: "tools", Prefixes: []string{"/v1/mcp", "/v1/plugins", "/v1/skills", "/v1/tools"}},
{Name: "tools", Prefixes: []string{"/v1/mcp/servers", "/v1/plugins", "/v1/skills", "/v1/tools"}},
{Name: "marketplace", Prefixes: []string{"/v1/marketplace"}},
{Name: "referrals", Prefixes: []string{"/v1/admin/referrals/bonuses", "/v1/admin/referrals/sweep", "/v1/referrals"}},
{Name: "guide", Prefixes: []string{"/v1/guide"}},
+223
View File
@@ -0,0 +1,223 @@
package manifest
// The gates under the fleet's ONE agent door.
//
// POST /v1/mcp is the host's, served by zip from the composed plugin catalogues
// (cmd/cloud/main.go, plugin/embed.go). Three things can silently take it away,
// and each one is pinned here:
//
// 1. an App row claiming /v1/mcp — a Load registers All(prefix) + All(prefix/*),
// and fiber MERGES byte-identical patterns into one route with both handlers
// chained, so the host's own POST would sit BEHIND a proxy handler and never
// run. Silent shadowing, not a panic.
// 2. a plugin growing a SECOND hand-rolled door — this fleet had three MCP tool
// registries for one concept, and the way back is one route registration.
// 3. a catalogue naming a tool no op answers, or two plugins naming one tool.
import (
"encoding/json"
"io/fs"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
)
// mcpDoor is the ONE public MCP path. The host claims it exactly, so a plugin
// prefix may be DEEPER (tools owns /v1/mcp/servers) but never equal.
const mcpDoor = "/v1/mcp"
// TestNoAppClaimsTheDoor: no manifest row may claim the exact door path.
func TestNoAppClaimsTheDoor(t *testing.T) {
for _, a := range Apps {
for _, p := range a.Prefixes {
if p == mcpDoor {
t.Fatalf("app %q claims %q, the host's own MCP door. A Load there registers "+
"All(%q), which fiber merges with the host's POST into one route — the door "+
"would sit behind the proxy handler and never run. Claim a DEEPER prefix "+
"(%q/servers) or none.", a.Name, p, p, p)
}
}
}
}
// TestNoSecondMCPDoor: no app may serve a path ENDING in /mcp.
//
// This is the structural reason a fourth registry cannot grow back. A hand-rolled
// JSON-RPC door can only exist as a route; every route an app serves is
// regenerated into its own subset by the drift gate (mk/fleet.mk surface-check);
// and the one true door is a zip CONTROL route, which is in no subset at all. So
// the next hand-rolled envelope turns this red and the message names the door it
// should have used instead.
//
// A path CONTAINING /mcp is fine — /v1/mcp/servers is the external MCP server
// registry, a real and different capability (records an org creates, not tools a
// registry enumerates).
func TestNoSecondMCPDoor(t *testing.T) {
for _, a := range Apps {
for _, p := range served(t, a.Name) {
if strings.HasSuffix(p, "/mcp") {
t.Errorf("app %q serves %q. The fleet has ONE MCP door: POST /v1/mcp on the host, "+
"composed from every plugin's build-time catalogue. A typed op is already a "+
"tool there — register one instead of a second JSON-RPC envelope.", a.Name, p)
}
}
}
}
// foreignDoors is the CLOSED list of routes ending in /mcp that this fleet serves
// and that are NOT a projection of our own typed ops. Each entry carries why.
//
// The list exists because the document cannot see every door: a subsystem that
// mounts a raw net/http mux registers routes zip never projects, so a path can be
// served and appear in no subset. TestNoSecondMCPDoorInSource reads the SOURCE
// for that reason, and a door with a real, foreign owner is named here rather
// than deleted — deleting it would remove a capability with nothing to replace it.
var foreignDoors = map[string]string{
// hanzoai/tasks' OWN MCP surface, served by the embedded engine's
// srv.MCPHandler() behind cloud's identity gate. Its tools are the task/workflow
// engine's, implemented in that module — they are not cloud typed ops, so the
// host's catalogue could not carry them and the one door does not duplicate
// them. Same class as apps/tools' EXTERNAL MCP server registry: a real
// capability with a different owner, reached through this fleet rather than
// projected from it.
"apps/tasks/tasks.go": "hanzoai/tasks' own engine tool surface (srv.MCPHandler), not a projection of cloud's typed ops",
}
// TestNoSecondMCPDoorInSource is the gate that makes a fourth registry impossible
// to add quietly. A hand-rolled door has to register a route, and a route needs a
// path literal — so any Go string ending in "/mcp" outside cmd/cloud is either the
// one door being moved (a deliberate edit here) or a rival being born.
//
// It reads SOURCE, which is what lets it see what the document cannot: apps/tasks'
// door is a raw net/http mux handler and appears in no subset at all.
func TestNoSecondMCPDoorInSource(t *testing.T) {
lit := regexp.MustCompile(`"[a-z0-9/_:.-]*/mcp"`)
root := filepath.Join("..")
for _, dir := range []string{"apps", "clients", "webui"} {
_ = filepath.WalkDir(filepath.Join(root, dir), func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
return nil
}
rel, _ := filepath.Rel(root, path)
src, err := os.ReadFile(path)
if err != nil {
return nil
}
for i, line := range strings.Split(string(src), "\n") {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "//") || !lit.MatchString(line) {
continue
}
if _, ok := foreignDoors[filepath.ToSlash(rel)]; ok {
continue
}
t.Errorf("%s:%d serves an MCP path: %s\n"+
"The fleet has ONE MCP door — POST /v1/mcp on the host, composed from every "+
"plugin's build-time catalogue. A typed op is ALREADY a tool there. If this is a "+
"foreign engine's own surface rather than a projection of our ops, name it in "+
"foreignDoors with the reason.", rel, i+1, trimmed)
}
return nil
})
}
}
// TestEveryCatalogueToolIsAnOpOfItsOwnApp: soundness of the composed door.
//
// mcp.json and openapi.json are two projections of ONE registry taken in one
// process at one instant (`<app> describe`), so this cannot fail while both are
// regenerated together — which is precisely why it is worth asserting: it is the
// cheap, no-build check that a HAND-EDITED catalogue, or one left behind by a
// half-run generator, does not publish a tool the owning app cannot answer.
//
// It also refuses two apps claiming one tool name. A name is dispatch, so a
// duplicate is unroutable; zip refuses it at Load (App.installTools), which is a
// boot failure. Catching it here makes it a red build instead.
func TestEveryCatalogueToolIsAnOpOfItsOwnApp(t *testing.T) {
owner := map[string]string{}
tools := 0
for _, a := range Apps {
ops := operationIDs(t, a.Name)
for _, name := range catalogue(t, a.Name) {
tools++
if _, ok := ops[name]; !ok {
t.Errorf("%s/mcp.json names tool %q, which is not an operationId in "+
"%s/openapi.json. The catalogue is a projection of the same typed-op "+
"registry the document is — regenerate: make -f mk/fleet.mk describe-apps",
a.Name, name, a.Name)
}
if held, dup := owner[name]; dup {
t.Errorf("tool %q is claimed by both %q and %q. A tool name is dispatch, so two "+
"owners make it unroutable and zip refuses the composition at boot — rename "+
"one op's operationId.", name, held, a.Name)
}
owner[name] = a.Name
}
}
if tools == 0 {
t.Fatal("the fleet's composed MCP door would carry ZERO tools — no plugin/<app>/mcp.json holds any")
}
t.Logf("%d MCP tools across %d apps on the one door", tools, len(Apps))
}
// catalogue is the tool names in an app's committed MCP catalogue.
func catalogue(t *testing.T, app string) []string {
t.Helper()
raw, err := os.ReadFile(filepath.Join("..", "plugin", app, "mcp.json"))
if err != nil {
t.Fatalf("%s: %v\n\nEvery app publishes its own MCP catalogue beside its subset. "+
"Run `make -f mk/fleet.mk describe-apps`.", app, err)
}
var tools []struct {
Name string `json:"name"`
Description string `json:"description"`
InputSchema json.RawMessage `json:"inputSchema"`
}
if err := json.Unmarshal(raw, &tools); err != nil {
t.Fatalf("%s/mcp.json: %v", app, err)
}
out := make([]string, 0, len(tools))
for _, tl := range tools {
// An op present with an EMPTY description is a SILENT failure: the model
// pays context for a nameless tool it cannot choose. That exact bug shipped
// once here (zipdoc blind to group prefixes), so it is a gate, not a hope.
if strings.TrimSpace(tl.Description) == "" {
t.Errorf("%s tool %q has an EMPTY description — the prose zipdoc lifts IS what a "+
"model reads to pick it. Write the doc comment and run: go generate -run zipdoc "+
"./apps/%s/...", app, tl.Name, app)
}
if len(tl.InputSchema) == 0 {
t.Errorf("%s tool %q has no inputSchema", app, tl.Name)
}
out = append(out, tl.Name)
}
return out
}
// operationIDs is every operationId an app's own subset publishes.
func operationIDs(t *testing.T, app string) map[string]bool {
t.Helper()
raw, err := os.ReadFile(filepath.Join("..", "plugin", app, "openapi.json"))
if err != nil {
t.Fatalf("%s: %v", app, err)
}
var doc struct {
Paths map[string]map[string]struct {
OperationID string `json:"operationId"`
} `json:"paths"`
}
if err := json.Unmarshal(raw, &doc); err != nil {
t.Fatalf("%s/openapi.json: %v", app, err)
}
out := map[string]bool{}
for _, item := range doc.Paths {
for _, op := range item {
if op.OperationID != "" {
out[op.OperationID] = true
}
}
}
return out
}
+2 -2
View File
@@ -21,7 +21,7 @@ package manifest
// The other side is each app's own subset (plugin/<app>/openapi.json), projected
// by that app's binary from that app's router. It is derived — but it is forced
// back to source on every `make test`, which regenerates it and fails on any diff
// (mk/fleet.mk openapi-check). Nothing here is forced back to anything by being
// (mk/fleet.mk surface-check). Nothing here is forced back to anything by being
// committed.
import (
@@ -185,7 +185,7 @@ func served(t *testing.T, app string) []string {
path := filepath.Join("..", "plugin", app, "openapi.json")
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("%s: %v\n\nEvery app publishes its own subset. Run `make -f mk/fleet.mk openapi-apps`.", path, err)
t.Fatalf("%s: %v\n\nEvery app publishes its own subset. Run `make -f mk/fleet.mk describe-apps`.", path, err)
}
var doc struct {
Paths map[string]json.RawMessage `json:"paths"`
+2 -2
View File
@@ -230,7 +230,7 @@ func TestBillingGate_NilClientIsNoop(t *testing.T) {
// REAL declarations, and it is where a change in what customers pay shows up.
func TestDefaultPrice(t *testing.T) {
index(t, &Config{Enable: []string{"ai", "agent", "agents", "commerce", "o11y", "iam", "base", "probe"}},
Plugin{Name: "ai", Price: Metered, Prefixes: []string{"/v1/ai", "/v1/mcp"}},
Plugin{Name: "ai", Price: Metered, Prefixes: []string{"/v1/ai", "/v1/tools"}},
Plugin{Name: "agent", Price: Metered},
Plugin{Name: "agents", Price: Metered},
Plugin{Name: "commerce", Price: Free},
@@ -246,7 +246,7 @@ func TestDefaultPrice(t *testing.T) {
}{
{"/v1/ai/chat/completions", 0, "ai declares Metered — the model plane's token meter owns the charge, so an edge charge double-bills"},
{"/v1/ai/embeddings", 0, "same surface, same declaration"},
{"/v1/mcp/tools/call", 0, "ai owns /v1/mcp too; per-tool dispatch meters downstream"},
{"/v1/tools/call", 0, "ai owns /v1/tools too; per-tool dispatch meters downstream"},
{"/v1/commerce/billing/usage", 0, "commerce declares Free — it IS the pay path"},
{"/v1/o11y/ingest", 0, "o11y declares Free — telemetry ingest is not user-billable"},
{"/health", 0, "liveness probe"},
+8 -8
View File
@@ -31,7 +31,7 @@ EXTERNAL := authz licensing metrics
# adaptor moves out to hanzoai/stream.
OPENAPI_NEEDS_BROKER := kafka
.PHONY: openapi-weave openapi-apps openapi-check
.PHONY: openapi-weave describe-apps surface-check
# FIRST, so a bare `make -f mk/fleet.mk` runs the two-second check and not the
# twelve-minute rebuild. (Included at the root it changes nothing: the default
@@ -49,15 +49,15 @@ openapi-weave: ## Weave the per-app subsets into the fleet spec and prove it equ
# loop, which mounted kafka, which fails closed without a live broker. So the one
# command told to repair a red gate could not run at all. One exemption list, read
# everywhere it applies.
openapi-apps: ## Regenerate EVERY app's own spec subset (one binary per app; slow by construction).
describe-apps: ## Regenerate EVERY app's own spec subset (one binary per app; slow by construction).
@set -e; for d in $(APPDIRS); do \
a=$$(basename $$d); \
case " $(OPENAPI_NEEDS_BROKER) " in \
*" $$a "*) echo ">> skip $$a — needs a live broker to mount (OPENAPI_NEEDS_BROKER)"; continue;; \
esac; \
$(MAKE) --no-print-directory -C $$d openapi; \
$(MAKE) --no-print-directory -C $$d describe; \
done
@for a in $(EXTERNAL); do $(MAKE) --no-print-directory -f $(ROOT)/mk/plugin.mk ROOT=$(ROOT) APPS=$$a openapi || exit 1; done
@for a in $(EXTERNAL); do $(MAKE) --no-print-directory -f $(ROOT)/mk/plugin.mk ROOT=$(ROOT) APPS=$$a describe || exit 1; done
@echo ">> $$(ls $(ROOT)/plugin/*/openapi.json | wc -l) app subsets"
# The drift gate. It REGENERATES FROM SOURCE and fails on any diff, which is the
@@ -82,18 +82,18 @@ openapi-apps: ## Regenerate EVERY app's own spec subset (one binary per app; slo
# It checks with `git status --porcelain`, not `git diff`: a NEW app produces a
# NEW subset, which is untracked and therefore invisible to a diff — the failure
# that matters most is exactly the one a diff would miss.
openapi-check: ## Regenerate every subset + the fleet spec FROM SOURCE and fail on any diff. The drift gate.
surface-check: ## Regenerate every subset + the fleet spec FROM SOURCE and fail on any diff. The drift gate.
@set -e; \
for d in $(APPDIRS); do \
a=$$(basename $$d); \
case " $(OPENAPI_NEEDS_BROKER) " in \
*" $$a "*) echo ">> skip $$a — needs a live broker to mount (OPENAPI_NEEDS_BROKER)"; continue;; \
esac; \
$(MAKE) --no-print-directory -C $$d openapi >/dev/null \
$(MAKE) --no-print-directory -C $$d describe >/dev/null \
|| { echo "!! $$a cannot project its own document — an app that cannot describe itself is the bug"; exit 1; }; \
done; \
for a in $(EXTERNAL); do \
$(MAKE) --no-print-directory -f $(ROOT)/mk/plugin.mk ROOT=$(ROOT) APPS=$$a openapi >/dev/null \
$(MAKE) --no-print-directory -f $(ROOT)/mk/plugin.mk ROOT=$(ROOT) APPS=$$a describe >/dev/null \
|| { echo "!! $$a cannot project its own document"; exit 1; }; \
done
@$(MAKE) --no-print-directory -f $(ROOT)/mk/fleet.mk openapi-weave OUT=$(ROOT)/openapi.yaml >/dev/null
@@ -107,7 +107,7 @@ openapi-check: ## Regenerate every subset + the fleet spec FROM SOURCE and fail
echo "undocumented, or documented and gone. The SDK repos pull this file, so a route"; \
echo "missing here is a route no generated client can reach."; \
echo ""; \
echo " fix: make openapi # then commit openapi.yaml and plugin/*/openapi.json"; \
echo " fix: make describe # then commit openapi.yaml and plugin/*/{openapi,mcp}.json"; \
echo ""; \
exit 1; \
fi
+24 -20
View File
@@ -2,13 +2,13 @@
# which is otherwise just the app's name.
#
# Why one file instead of a target per app: `build` is the same command for every
# app, and so are `test`, `vet`, `openapi` and `clean`. A target written once per
# app, and so are `test`, `vet`, `describe` and `clean`. A target written once per
# app is one place per app for them to disagree — and they would, because nobody
# edits a hundred files at once. Here the contract has ONE definition and each app
# supplies the ONE thing that actually varies: its name.
#
# It works from the repo root (`make -C apps/tasks openapi`) and from inside
# the app (`cd apps/tasks && make openapi`), because everything below is
# It works from the repo root (`make -C apps/tasks describe`) and from inside
# the app (`cd apps/tasks && make describe`), because everything below is
# absolute and derived from the including Makefile's own location — never from
# the caller's cwd. That is not a convenience: task #49 extracts apps into their
# own repos, and an extracted apps/<app> + plugin/<app> + mk/ keeps these paths
@@ -35,7 +35,7 @@ include $(ROOT)/mk/go.mk
BIN := $(ROOT)/bin
.DEFAULT_GOAL := help
.PHONY: help generate build test vet openapi clean
.PHONY: help generate build test vet describe clean
help: ## Show this help.
@awk 'BEGIN{FS=":.*##";printf "\n%s: make <target>\n\n", "$(APPS)"} /^[a-zA-Z_-]+:.*##/{printf " \033[36m%-10s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
@@ -49,7 +49,7 @@ help: ## Show this help.
# /v1/openapi.json is missing every description. `make test` runs zipdoc -check,
# so a lift that drifts from its source turns CI red instead of shipping stale.
#
# It is a prerequisite of `build`, not of `openapi`, because the generated file
# It is a prerequisite of `build`, not of `describe`, because the generated file
# is compiled INTO the binary — running it after the build would be too late.
#
# An external app's source is another module: nothing here to lift, and nothing
@@ -74,30 +74,34 @@ test: ## Run this app's tests.
vet: ## go vet this app and its entrypoint(s).
@CGO_ENABLED=$(CGO_ENABLED) $(GO) vet $(APPDIR)/... $(foreach a,$(APPS),$(ROOT)/plugin/$(a))
# The app's OWN subset of the API document, from the app's OWN live router: the
# binary mounts one subsystem and projects it through the same
# openapi.FleetSpec the whole-fleet golden is projected through (openapi_dump.go).
# It is never sliced out of the fleet spec by prefix — that would make the fleet
# the source and the app a derivative, which is backwards and is exactly how a
# catch-all silently swallows a neighbour's routes.
# The app's OWN projections, from the app's OWN live router: the binary mounts one
# subsystem and projects it through the same openapi.FleetSpec the whole-fleet
# golden is projected through AND the same zip MCPTools the host's agent door is
# composed from (describe.go). Never sliced out of the fleet spec by prefix — that
# would make the fleet the source and the app a derivative, which is backwards and
# is exactly how a catch-all silently swallows a neighbour's routes.
#
# `build` first, because a spec generated from a stale binary is a lie.
# ONE invocation writes BOTH openapi.json and mcp.json, from one mount at one
# instant over one registry, so the document and the tool catalogue cannot be
# generated apart and therefore cannot disagree.
#
# `build` first, because a projection taken from a stale binary is a lie.
#
# GIT_SSH_ADDR: mounting is not free of side effects — apps/git opens a real
# SSH listener on a fixed :2222 — and a document is a projection of routes, not a
# SSH listener on a fixed :2222 — and a projection is a function of routes, not a
# reason to contend for a port with a cloud already running on the box. The same
# ephemeral-port convention the shared openapi_dump spec harness uses.
# ephemeral-port convention the shared describe.go spec harness uses.
#
# The binary is handed the PATH, never a redirect: a subsystem's dependencies
# The binary is handed the DIRECTORY, never a redirect: a subsystem's dependencies
# write to stdout at mount (hanzoai/commerce prints a sqlite-vec warning and GORM
# debug lines), and `> file` splices those into the front of the document.
openapi: build ## Emit this app's own subset of the API document into plugin/<app>/openapi.json.
describe: build ## Emit this app's own OpenAPI subset + MCP tool catalogue into plugin/<app>/.
@for a in $(APPS); do \
echo ">> openapi $$a"; \
GIT_SSH_ADDR=127.0.0.1:0 $(BIN)/$$a openapi $(ROOT)/plugin/$$a/openapi.json || exit 1; \
echo ">> describe $$a"; \
GIT_SSH_ADDR=127.0.0.1:0 $(BIN)/$$a describe $(ROOT)/plugin/$$a || exit 1; \
done
# Binaries only. plugin/<app>/openapi.json is a committed artifact, like the fleet's
# openapi.yaml — `clean` removes what a build wrote, not what a build publishes.
# Binaries only. plugin/<app>/{openapi,mcp}.json are committed artifacts, like the
# fleet's openapi.yaml — `clean` removes what a build wrote, not what it publishes.
clean: ## Remove this app's built binary.
@rm -f $(foreach a,$(APPS),$(BIN)/$(a))
+61 -73
View File
@@ -5575,8 +5575,8 @@ components:
Enforcement is the x402 settlement seam; this is the declaration.
source:
description: |-
Source is where the tool comes from: builtin, connector, function,
zap-service, agent, skill or mcp.
Source is where the tool comes from: connector, function, zap-service,
agent, skill or mcp.
type: string
type: object
Top:
@@ -13426,24 +13426,6 @@ components:
title:
type: string
type: object
mcpRequest:
properties:
id: {}
jsonrpc:
type: string
method:
type: string
params: {}
type: object
mcpResponse:
properties:
error:
$ref: '#/components/schemas/rpcErrorBody'
id: {}
jsonrpc:
type: string
result: {}
type: object
mcpServerList:
properties:
servers:
@@ -16378,13 +16360,6 @@ components:
own default.
type: integer
type: object
rpcErrorBody:
properties:
code:
type: integer
message:
type: string
type: object
rulesOut:
properties:
rules:
@@ -18164,6 +18139,19 @@ components:
httpStatus:
type: integer
type: object
toolCall:
properties:
arguments:
additionalProperties:
type: object
description: |-
Arguments is the tool's own input object, passed through verbatim to
whichever source owns it.
type: object
name:
description: Name is the tool to run, exactly as GET /v1/tools reports it.
type: string
type: object
toolList:
properties:
tools:
@@ -18174,6 +18162,17 @@ components:
$ref: '#/components/schemas/Tool'
type: array
type: object
toolResult:
properties:
name:
description: Name is the tool that ran.
type: string
result:
description: |-
Result is the tool's own output, verbatim — its shape is the tool's, not
this plane's.
type: object
type: object
trackerProject:
properties:
createdAt:
@@ -24925,11 +24924,6 @@ paths:
type: string
tags:
- automations
/v1/automations/mcp:
post:
operationId: post_v1_automations_mcp
tags:
- automations
/v1/automations/pieces:
get:
description: |-
@@ -40206,35 +40200,6 @@ paths:
so it stops being dispatchable there.
tags:
- marketplace
/v1/mcp:
get:
description: |-
ListMCPTools lists the tools reachable on the external MCP servers the caller's
org has registered, with each one's activation flag. Every name is prefixed by
the server id it came from, which is what keeps two servers offering "search"
from colliding. It is GET /v1/tools narrowed to one source, so an external tool
still cannot shadow a native one — mcp is the lowest-precedence source.
operationId: get_v1_mcp
parameters:
- description: |-
Activated keeps only the tools activated for the caller's org and project,
and only when it is exactly the string "true".
in: query
name: activated
required: false
schema:
type: string
responses:
"200":
content:
application/json:
schema:
$ref: '#/components/schemas/sourceToolList'
description: ok
summary: ListMCPTools lists the tools reachable on the external MCP servers
the caller's org has registered, with each one's activation flag.
tags:
- mcp
/v1/mcp/servers:
get:
description: |-
@@ -45383,15 +45348,15 @@ paths:
description: |-
ListTools lists every tool the caller's org and project can reach, from every
source, each flagged with whether it is activated. This is the discovery
surface: one flat set of names spanning builtin cloud controls, connector
actions, user functions, zap-service routes, agents, skills and the org's own
external MCP servers, deduplicated by name so the highest-precedence source
wins a collision. It lists; it does not call — dispatch is the MCP endpoint.
surface: one flat set of names spanning connector actions, user functions,
zap-service routes, agents, skills and the org's own external MCP servers,
deduplicated by name so the highest-precedence source wins a collision. It
lists; it does not call — dispatch is POST /v1/tools/call.
operationId: get_v1_tools
parameters:
- description: |-
Source keeps only tools from one source — builtin, connector, function,
zap-service, agent, skill or mcp. Empty keeps every source.
Source keeps only tools from one source — connector, function, zap-service,
agent, skill or mcp. Empty keeps every source.
in: query
name: source
required: false
@@ -45465,21 +45430,44 @@ paths:
and answers with the resulting activated set.
tags:
- tools
/v1/tools/mcp:
/v1/tools/call:
post:
operationId: post_v1_tools_mcp
description: |-
CallTool runs one of the caller's activated tools and answers with its output.
This is the door onto the tool plane's DYNAMIC half — the half no build-time
catalogue can hold, because it is per-tenant: an org's connected connector
actions, its authored skills, its agents and functions, and the tools of every
external MCP server it registered. A tool's existence, its price and its
activation are all rows, not code, so they cannot be known until the caller is.
One policy, the registry's: resolve by precedence, refuse an unactivated tool
403, settle a priced one through the x402 seam or fail closed 402, then
dispatch to the winning source bound to the caller's own (org, project). One
metered unit, one audit record. A caller can only ever dispatch its own tools.
Discovery is GET /v1/tools — ?activated=true for the callable set.
operationId: post_v1_tools_call
requestBody:
content:
application/json:
example:
arguments:
channel: '#general'
text: hi
name: slack_post_message
schema:
$ref: '#/components/schemas/mcpRequest'
$ref: '#/components/schemas/toolCall'
required: true
responses:
2XX:
"200":
content:
application/json:
schema:
$ref: '#/components/schemas/mcpResponse'
description: Success
$ref: '#/components/schemas/toolResult'
description: ok
summary: CallTool runs one of the caller's activated tools and answers with
its output.
tags:
- tools
/v1/traces/health:
+2 -2
View File
@@ -13,7 +13,7 @@ package openapi_test
// What it proves is COMPOSITION and only composition: that the subsets compose
// without two apps claiming one address or one schema name, and that the golden
// is what they compose to. Both sides are derived, so it cannot prove either is
// still the routes — `make -f mk/fleet.mk openapi-check` regenerates them from
// still the routes — `make -f mk/fleet.mk surface-check` regenerates them from
// source for that, and manifest/router_test.go asks the router whether the fleet
// delivers what they describe. Three questions, three gates, each answered where
// its answer lives.
@@ -65,7 +65,7 @@ func parts(t *testing.T) []openapi.Part {
path := filepath.Join(specDir, a.Name, "openapi.json")
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("%s: %v\n\nEvery app publishes its own subset. Run `make -f mk/fleet.mk openapi-apps`.", path, err)
t.Fatalf("%s: %v\n\nEvery app publishes its own subset. Run `make -f mk/fleet.mk describe-apps`.", path, err)
}
var doc openapi.Document
if err := json.Unmarshal(raw, &doc); err != nil {
-120
View File
@@ -1,120 +0,0 @@
package cloud
// `<binary> openapi` — any cloud binary describes itself instead of serving.
//
// This is the ONE producer of a per-app spec subset. An app's document is not
// sliced out of the fleet's by prefix (that would make the fleet the source and
// the app a derivative, exactly backwards); it is generated from the app's OWN
// live router by the SAME openapi.FleetSpec the whole document is generated
// from, over an app with only that subsystem mounted. Compose upward, never
// carve downward — see openapi/weave.go for the other half.
//
// It lives on Serve because Serve is the single entry every app binary shares:
// plugin/<app>/main.go is generated as one cloud.Serve call, so putting the mode
// here gives every one of them the target at a cost of zero per-app code. A binary
// that mounts its own app (cmd/o11y) calls WriteSpec directly.
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"github.com/hanzoai/cloud/openapi"
"github.com/zap-proto/zip"
)
// specArg is the argv word that switches a binary from serving to describing.
const specArg = "openapi"
// SpecRequested reports whether argv asks this binary for its document, and the
// file it named: `<binary> openapi <file>`. Read before any flag parsing — the
// mode is a mode, not an option.
//
// A FILE and not stdout, and the argument is required. A subsystem's own
// dependencies write to stdout: hanzoai/commerce prints a sqlite-vec warning and
// GORM debug lines there at mount, which a `> file` redirect splices into the
// front of the document and turns into 71KB of invalid JSON. A writer whose
// output an unrelated library can corrupt is not a writer. `openapi /dev/stdout`
// remains available for a human reading it.
func SpecRequested() (string, bool) {
if len(os.Args) > 1 && os.Args[1] == specArg {
if len(os.Args) > 2 {
return os.Args[2], true
}
return "", true
}
return "", false
}
// SpecConfig is the deployment the PUBLISHED document describes, and the whole
// of it — zero values everywhere else, on purpose. The returned func removes the
// throwaway data dir.
//
// A published spec must be a function of the code alone. Config decides routes:
// clients/kms registers its secret routes only when a master key resolved, and
// several subsystems gate on brand. If this read the environment, the document
// two developers generated from one commit would differ by whichever CLOUD_*
// variables their shells carried, and the golden would flap in CI for a reason
// no diff could explain. So it reads nothing.
//
// The data dir is a throwaway and is created HERE rather than taken as an
// argument, because mounting opens real stores: the default is /var/lib/cloud,
// and a caller that forgot to override it would either migrate a live store or
// (as cmd/o11y did) fail on it.
func SpecConfig() (*Config, func(), error) {
dir, err := os.MkdirTemp("", "openapi-spec-*")
if err != nil {
return nil, nil, err
}
return &Config{Brand: DefaultBrand, Domain: "api.hanzo.ai", DataDir: dir},
func() { os.RemoveAll(dir) }, nil
}
// WriteSpec writes app's document to path, as indented JSON.
//
// JSON, not YAML, because JSON is what the document IS — the same bytes served
// at /v1/openapi.json and dropped into hanzoai/openapi — and because encoding to
// YAML would put a yaml library in the graph of every app binary to write a
// file only the weave reads. Indented so a subset reviews as a diff.
//
// The whole document is rendered before the file is touched, so a mount or
// projection failure leaves the previous artifact intact rather than truncating
// it into an app that appears to serve nothing.
func WriteSpec(path string, app *zip.App) error {
if path == "" {
return fmt.Errorf("usage: %s %s <file>", filepath.Base(os.Args[0]), specArg)
}
doc, err := openapi.FleetSpec(app)
if err != nil {
return fmt.Errorf("openapi: %w", err)
}
b, err := json.MarshalIndent(doc, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, append(b, '\n'), 0o644)
}
// dumpSpec mounts specs into a throwaway app and writes its document.
//
// Enablement is cfg's default, NOT the forced single-service list Serve applies:
// this is the fleet's document, and a STAGED subsystem (config.go's
// a deployment does not name) is linked but inert until it does. Describing
// one here would publish routes api.hanzo.ai does not serve, and would make the
// woven document disagree with the fully-mounted golden — which is the equality
// the composition proof rests on.
func dumpSpec(specs []Plugin, path string) error {
cfg, done, err := SpecConfig()
if err != nil {
return err
}
defer done()
deps := BuildDeps(cfg)
app := zip.New(zip.Config{Logger: deps.Logger, DisableStartupMessage: true})
if err := MountAll(app, specs, cfg, deps); err != nil {
return err
}
return WriteSpec(path, app)
}
+1
View File
@@ -0,0 +1 @@
[]
+137
View File
@@ -0,0 +1,137 @@
[
{
"description": "RevokeKey revokes the caller's own API key of the requested class. The class is\nthe same field mint takes — `?type=publishable`, defaulting to secret — so\nrevoking the key that ships in a browser bundle does not sign its holder out of\ntheir own API: the other key keeps working.\n\nRevoking is how a key is replaced when it does not need replacing; minting the\nsame class again rotates it in one step. IAM drops the credential immediately,\nbut the gateway caches keys for a few minutes, so a request that beat the cache\nexpiry may still be served.\n\nFor callers written against the older shape, the class is also accepted in a JSON\nrequest body, read only when `?type=` is absent.",
"inputSchema": {
"properties": {
"type": {
"description": "Type is the key class to act on: \"secret\" (sk-, session-equivalent, belongs\non a server) or \"publishable\" (pk-, org-identifying, safe in a browser\nbundle). Omitted means secret, which is what every existing caller means.",
"type": "string"
}
},
"type": "object"
},
"name": "delete_v1_iam_keys"
},
{
"description": "RevokeKey revokes the caller's own API key of the requested class. The class is\nthe same field mint takes — `?type=publishable`, defaulting to secret — so\nrevoking the key that ships in a browser bundle does not sign its holder out of\ntheir own API: the other key keeps working.\n\nRevoking is how a key is replaced when it does not need replacing; minting the\nsame class again rotates it in one step. IAM drops the credential immediately,\nbut the gateway caches keys for a few minutes, so a request that beat the cache\nexpiry may still be served.\n\nFor callers written against the older shape, the class is also accepted in a JSON\nrequest body, read only when `?type=` is absent.",
"inputSchema": {
"properties": {
"type": {
"description": "Type is the key class to act on: \"secret\" (sk-, session-equivalent, belongs\non a server) or \"publishable\" (pk-, org-identifying, safe in a browser\nbundle). Omitted means secret, which is what every existing caller means.",
"type": "string"
}
},
"type": "object"
},
"name": "delete_v1_keys"
},
{
"description": "TopupRails lists the accepted (chain, token, treasury) triples, so a browser can\nrender \"send USDC here\" without the addresses being baked into its bundle.\n\nThis exists because the console previously gated its top-up UI on\nNEXT_PUBLIC_HANZO_HUSD_ADDRESS/_TREASURY — build-time constants. Enabling a rail\ntherefore meant rebuilding and redeploying the frontend, and with them unset the\nUI reported \"not available yet\" no matter what the server could actually accept.\nServing the set at runtime keeps ONE source of truth (the server's config) and\nlets a rail be switched on without shipping a bundle.\n\nEverything here is public on-chain data; no secret is exposed, and the set is\nempty on a deployment that accepts no crypto rail.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "get_v1_commerce_topup_rails"
},
{
"description": "IssueCSRFToken mints the anti-CSRF token a browser echoes as X-CSRF-Token on\nevery money write (mint/revoke a key, top up, onboard, and the billing/commerce\nwrite verbs). The token is bound to the caller's validated identity and expires,\nso one minted for one identity cannot authorize a write as another.\n\nIt is answered no-store, so it is never cached by a shared proxy. This is the\nsame-origin endpoint the embedded console reads — the Same-Origin Policy is what\nstops a cross-site page from reading the response and forging a write.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "get_v1_csrf"
},
{
"description": "EmbedStatus reports whether one of this brand's shared embedded apps (cms, erp,\nhelp) may be framed by the caller and is actually running, so a console module\ncan choose between the embed and the provision panel.\n\nIt answers two questions the browser cannot answer for itself. ENTITLEMENT is\nserver-authoritative: each app is a single shared per-BRAND instance, so only a\nmember of the owning brand org — or a SuperAdmin — is given the embed URL; every\nother caller gets phase \"not-entitled\" and no URL. REACHABILITY is a probe of\nthat origin, which a cross-origin page cannot read for itself.\n\nThe probed host is always \u003capp\u003e.\u003cthis deployment's own brand domain\u003e: no part of\nit comes from the request, so this can never be steered into probing an\narbitrary origin.",
"inputSchema": {
"properties": {
"app": {
"description": "App is the embedded app to report on: cms (Content Studio), erp or help.",
"type": "string"
}
},
"type": "object"
},
"name": "get_v1_embed-status"
},
{
"description": "GetKey returns the caller's own API keys — every type they hold, read\nAUTHORITATIVELY from IAM rather than from the session claim, which lags a key\nminted moments ago. No secret material comes back: a secret key is represented\nby its prefix, and only a publishable key (public by construction) carries its\nfull value.\n\nA transient IAM read failure reports an empty set rather than a 5xx, so the\npage shows the honest empty state and never a fabricated key.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "get_v1_iam_keys"
},
{
"description": "GetKey returns the caller's own API keys — every type they hold, read\nAUTHORITATIVELY from IAM rather than from the session claim, which lags a key\nminted moments ago. No secret material comes back: a secret key is represented\nby its prefix, and only a publishable key (public by construction) carries its\nfull value.\n\nA transient IAM read failure reports an empty set rather than a 5xx, so the\npage shows the honest empty state and never a fabricated key.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "get_v1_keys"
},
{
"description": "WalletTopup credits the caller's org for a stablecoin transfer they already sent\nto the treasury. It reads the receipt from that rail's chain, confirms a mined,\nsuccessful ERC-20 Transfer to the rail's treasury, derives USD cents from the\non-chain value using the token's own decimals, records the credit, and returns\nthe amount plus the new balance.\n\nThe credited amount is the ON-CHAIN value, never a number the caller sends, and\nthe credit lands on the caller's own validated org — there is no way to name a\nthird-party subject. Nothing is credited that the chain did not confirm: a\nmissing, failed or non-matching transaction is refused, and a deployment with no\npayment rail enabled says so rather than inventing a credit.",
"inputSchema": {
"properties": {
"fromAddress": {
"description": "FromAddress is the wallet the transfer was sent from. Optional; when given it\nmust match the transfer's on-chain sender.",
"type": "string"
},
"rail": {
"description": "Which accepted rail the transfer was sent on, e.g. \"base-usdc\". The client\nnames it rather than the server guessing from the tx: the same address can\nexist on several chains, so inferring would risk crediting against the wrong\ntreasury. It may be omitted only while exactly one rail is enabled.",
"type": "string"
},
"txHash": {
"description": "TxHash is the hash of the ERC-20 transfer that was already sent to the rail's\ntreasury. The receipt is read from that chain; nothing is credited that the\nchain did not confirm.",
"type": "string"
}
},
"type": "object"
},
"name": "post_v1_commerce_topup_wallet"
},
{
"description": "MintKey creates — or rotates — the caller's API key of the requested type and\nreturns it ONCE. A real IAM failure surfaces as 502, never a fabricated key.\n\nRotating is what creating means here: a user holds one key per type, so the\nendpoint is idempotent by (caller, type) and the superseded credential stops\nworking. Two live secrets for one user would make \"revoke my key\" a lie.",
"inputSchema": {
"properties": {
"type": {
"description": "Type is the key class to act on: \"secret\" (sk-, session-equivalent, belongs\non a server) or \"publishable\" (pk-, org-identifying, safe in a browser\nbundle). Omitted means secret, which is what every existing caller means.",
"type": "string"
}
},
"type": "object"
},
"name": "post_v1_iam_keys"
},
{
"description": "Onboard creates the caller's organization. Two flows, keyed on whether the caller\nalready has a home org (mirrors app/onboard/route.ts):\n\n - FIRST-RUN (no owner): create + MOVE the user in as admin, so their next JWT\n carries the new owner and the cloud scopes everything to it.\n - ADDITIONAL (owner set): create the org but do NOT move the user — a move\n changes their IAM owner (stripping a SuperAdmin's status + orphaning their\n current org). They reach the new org via the OrgSwitcher, which re-scopes\n X-Org-Id without touching IAM membership. A personal-org request from someone\n who already has an org is meaningless → 409.",
"inputSchema": {
"properties": {
"name": {
"description": "Name is the organization's display name. Ignored when personal is true, which\nderives the name from the caller's own username instead.",
"type": "string"
},
"personal": {
"description": "Personal asks for the caller's own workspace: the name is derived from their\nusername and the slug auto-suffixes to stay unique. Meaningless — and refused\n— for a caller who already has an organization.",
"type": "boolean"
}
},
"type": "object"
},
"name": "post_v1_iam_onboard"
},
{
"description": "MintKey creates — or rotates — the caller's API key of the requested type and\nreturns it ONCE. A real IAM failure surfaces as 502, never a fabricated key.\n\nRotating is what creating means here: a user holds one key per type, so the\nendpoint is idempotent by (caller, type) and the superseded credential stops\nworking. Two live secrets for one user would make \"revoke my key\" a lie.",
"inputSchema": {
"properties": {
"type": {
"description": "Type is the key class to act on: \"secret\" (sk-, session-equivalent, belongs\non a server) or \"publishable\" (pk-, org-identifying, safe in a browser\nbundle). Omitted means secret, which is what every existing caller means.",
"type": "string"
}
},
"type": "object"
},
"name": "post_v1_keys"
}
]
+969
View File
@@ -0,0 +1,969 @@
[
{
"description": "aimetrics is the fleet AI board: O11yAI generations (count, cost, avg/p95 latency,\nper-model), per-model usage from the live cloud_usage ledger, and the eval plane\n(traces, scores, score names, runs, and the average-score trend).\n\nEvery signal degrades INDEPENDENTLY — a table that is absent or errors contributes its\nzero value and the read still succeeds. O11yAI latency is a SEPARATE query from\ngenerations and cost on purpose: a Nullable end_time or a column mismatch there must\nnot zero the two numbers that did read.",
"inputSchema": {
"properties": {
"range": {
"description": "Range is the lower time bound: 24h, 7d or 30d. Anything else reads as the\nboard's own default.",
"type": "string"
}
},
"type": "object"
},
"name": "adminAIMetrics"
},
{
"description": "analytics is the SaaS product-analytics board over the caller's tenant window: active\ncustomers, new and churned, retention, MRR, ARPU, the usage trend and the top\ncustomers by spend — every number folded from the commerce ledger, not sampled.\n\nThe window is the caller's, not the fleet's: a SuperAdmin gets every org, a\nwhite-label admin only their own subtree (core.ScopedOrgs, the one scope predicate).\n\nsources[] carries each upstream's freshness so a partial read is VISIBLE rather than\nsilently low: a ledger that answered for only some orgs marks commerce-ledger degraded\ninstead of publishing an undercount as healthy.",
"inputSchema": {
"properties": {
"range": {
"description": "Range is the lower time bound: 24h, 7d or 30d. Anything else reads as the\nboard's own default.",
"type": "string"
}
},
"type": "object"
},
"name": "adminAnalytics"
},
{
"description": "applications lists IAM applications for one owner org, forwarded VERBATIM from IAM's\nget-applications. These are the platform's OIDC clients — the console reads clientId\noff each row.",
"inputSchema": {
"properties": {
"owner": {
"description": "Owner is the org whose rows to read. Defaults to the admin org, which owns the\nplatform's roles and applications.",
"type": "string"
},
"p": {
"description": "Page is the 1-based page number. Forwarded only when set — IAM applies its own\ndefault otherwise.",
"type": "string"
},
"pageSize": {
"description": "PageSize is rows per page. Forwarded only when set.",
"type": "string"
}
},
"type": "object"
},
"name": "adminApplications"
},
{
"description": "Records reads cloud's tamper-evident audit trail, newest first, with the chain's live\nintegrity attached so a listing can be badged as verified.\n\nWhen cloud has no local store configured it falls back to forwarding IAM's own\nget-records trail verbatim — a DIFFERENT trail, federated so the endpoint never\nregresses to an empty list. Those rows carry no integrity of ours, so the field is\nnull there.",
"inputSchema": {
"properties": {
"action": {
"description": "Action restricts it to one action name, e.g. \"admin.waitlist.grant\".",
"type": "string"
},
"org": {
"description": "Org restricts the trail to one tenant.",
"type": "string"
},
"p": {
"description": "Page is the 1-based page number, driving the offset.",
"type": "string"
},
"pageSize": {
"description": "PageSize is rows per page, default 100.",
"type": "string"
},
"resource": {
"description": "Resource restricts it to one resource kind, e.g. \"credit-grant\".",
"type": "string"
},
"resourceId": {
"description": "ResourceID restricts it to one resource instance.",
"type": "string"
},
"result": {
"description": "Result restricts it to \"success\" or \"error\".",
"type": "string"
},
"since": {
"description": "Since is the inclusive lower time bound, RFC3339. An unparseable value is\nignored rather than refused — one malformed filter must not hide the trail.",
"type": "string"
},
"sub": {
"description": "Sub restricts it to one actor (the validated subject that made the request).",
"type": "string"
},
"until": {
"description": "Until is the upper time bound, RFC3339, with the same tolerance.",
"type": "string"
}
},
"type": "object"
},
"name": "adminAudit"
},
{
"description": "Verify walks the WHOLE hash chain and reports whether it is intact: how many records\nwere checked, the head hash to pin externally against tail-truncation, and — when the\nchain is broken — the seq of the first bad record and why.\n\nbrokenAt is -1 exactly when ok is true. An unconfigured store is an honest failure\nhere rather than a fabricated pass.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "adminAuditVerify"
},
{
"description": "bases lists the tenant Base instances in the caller's window — a SuperAdmin sees every\ntenant's, anyone else only their own subtree's.\n\nThe scope is enforced TWICE: the upstream is asked for the caller's org, AND every row\nit returns is re-checked against the resolved scope. An upstream that ignored the\nfilter therefore degrades to empty, never to a cross-tenant leak.\n\nThe Base engine is being embedded into cloud; until it lands this proxies\nBASE_ADMIN_URL and, when that is unset, answers 200 with an empty list and msg saying\nso — the honest not-yet state, never fabricated instances.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "adminBases"
},
{
"description": "blockStorage is the realtime block-storage board: the DigitalOcean volume fleet\n(count, capacity, monthly list cost, per-volume region and attachment) plus the\nanalytics datastore's OWN fill, read from its system.disks.\n\nA volume's usedGiB and pct are null, always: DO exposes capacity and attachment but no\nfill, so the console renders \"—\" rather than a number nobody measured. The datastore\ncard is the one real fill here, and it is the number to scale on.\n\nThe two sources degrade independently — a DO outage still returns the datastore fill,\nand a disconnected datastore still returns the DO fleet.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "adminBlockStorage"
},
{
"description": "compute rolls the fleet's compute usage up to one row per (org, app, project, kind):\nhow many distinct machines ran in the window, how many are still active, what they\nbilled, and when each group last emitted an event. The console folds these into its\norg → app → project tree.\n\nA machine counts as ACTIVE when its LATEST lifecycle event is not a terminal one\n(stop/destroy/terminate/delete/off/shutdown/expire and their past tenses) — the same\nfold the console applies, done in the warehouse so the count is over every machine and\nnot just the page.\n\nHonest-empty when the warehouse is not connected or hanzo.compute_usage is not\nprovisioned yet: an empty list, never a fabricated fleet.",
"inputSchema": {
"properties": {
"kind": {
"description": "Kind narrows to one workload class (bot | machine | cluster | nodepool |\ncontainer | function | …). An OPEN spectrum matched as a plain string, lowercased\nto the warehouse's convention; empty means every kind.",
"type": "string"
},
"org": {
"description": "Org narrows to one tenant. Empty means every tenant — this board is\ncross-tenant by nature.",
"type": "string"
},
"range": {
"description": "Range is the lower time bound: 24h, 7d or 30d. Anything else reads as 30d.",
"type": "string"
}
},
"type": "object"
},
"name": "adminCompute"
},
{
"description": "cordonNode marks one cluster node unschedulable — or schedulable again — and can drain\nthe pods already on it.\n\nIt is the ONE infra change that does not go through the run discipline, because there\nis no destructive verdict to check: cordoning is reversible and evicting respects the\ncluster's own PodDisruptionBudgets. It reads the cached board for the same reason.\nThe outcome is audited either way, and the result reports how many pods were evicted.",
"inputSchema": {
"properties": {
"cordon": {
"description": "Cordon true marks the node unschedulable; false restores it.",
"type": "boolean"
},
"drain": {
"description": "Drain additionally evicts the pods already running there.",
"type": "boolean"
},
"id": {
"description": "ID is the node's droplet id, from the path.",
"type": "string"
}
},
"type": "object"
},
"name": "adminCordonNode"
},
{
"description": "createCreditGrant mints credit for one org. It is the ONE admin mint surface, and it\ndoes NOT mint in-process: it forwards the request to commerce's already-mint-gated\nPOST /v1/billing/credit-grants, authenticated by the service token and scoped to the\ntarget org, then writes one tamper-evident compliance record. Commerce stays the sole\ncredit ledger; this is a thin, audited relay so there is exactly one place credit is\ncreated.\n\nThe body is commerce's OWN CreateCreditGrant contract, forwarded whole — every field\nit carries reaches commerce. The only two this layer reads are the target org (`org`,\nor `user` as the org-pool alias), which selects the namespace commerce's EdgeAuth\ntrusts, and `idempotencyKey`, which makes a double-clicked grant credit once.\n\nA FAILED grant is audited too, with the request body attached: an attempted mint is\nexactly as interesting to a compliance auditor as a successful one.",
"inputSchema": {
"additionalProperties": {
"type": "object"
},
"type": "object"
},
"name": "adminCreateCreditGrant"
},
{
"description": "createSpendCap sets a usage cap on one org — a platform override of a customer budget,\nwritten to the customer's own spend-alert rows. The body is commerce's spend-alert\ncontract, forwarded byte-for-byte.",
"inputSchema": {
"properties": {
"id": {
"description": "ID is the cap to edit or remove, from the path. Unused by the list and create ops.",
"type": "string"
},
"org": {
"description": "Org is the tenant to act on. Required for a SuperAdmin — they must name their\ntarget; ignored for a white-label admin, who always acts on their own org.",
"type": "string"
}
},
"type": "object"
},
"name": "adminCreateSpendCap"
},
{
"description": "CustomerDetail answers GET /v1/admin/customers/:org.",
"inputSchema": {
"properties": {
"org": {
"description": "Org is the tenant slug from the path.",
"type": "string"
}
},
"type": "object"
},
"name": "adminCustomer"
},
{
"description": "Customers lists every customer org at a glance, sorted by slug: owner email, plan,\nsuspend status, member count, balance, month-to-date spend and MRR.\n\nEach row costs one IAM read plus the org's money reads, fanned out under a fixed\nconcurrency ceiling so a large fleet cannot stampede the upstreams. Every read is\nbest-effort per row: an upstream miss degrades THAT field to its honest zero rather\nthan failing the fleet.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "adminCustomers"
},
{
"description": "deleteDroplet destroys a droplet the board has just proven is NOT a DOKS node. There\nis no snapshot-first undo for a droplet the way there is for a volume: the local disk\ngoes with it.",
"inputSchema": {
"properties": {
"disk": {
"description": "Disk requests a PERMANENT resize that grows the disk. DO can never resize such a\ndroplet down again, so it defaults false — a CPU/RAM-only change, reversible.",
"type": "boolean"
},
"id": {
"description": "ID is the DO droplet id, from the path. Numeric.",
"type": "string"
},
"size": {
"description": "Size is the target DigitalOcean size slug on resize, e.g. \"s-4vcpu-8gb\".",
"type": "string"
}
},
"type": "object"
},
"name": "adminDeleteDroplet"
},
{
"description": "deleteLoadBalancer destroys a load balancer the board has just proven no live\ntype=LoadBalancer Service in any cluster targets.",
"inputSchema": {
"properties": {
"id": {
"description": "ID is the DO load balancer id, from the path.",
"type": "string"
}
},
"type": "object"
},
"name": "adminDeleteLoadBalancer"
},
{
"description": "deleteSpendCap removes one cap by id, lifting the ceiling entirely.",
"inputSchema": {
"properties": {
"id": {
"description": "ID is the cap to edit or remove, from the path. Unused by the list and create ops.",
"type": "string"
},
"org": {
"description": "Org is the tenant to act on. Required for a SuperAdmin — they must name their\ntarget; ignored for a white-label admin, who always acts on their own org.",
"type": "string"
}
},
"type": "object"
},
"name": "adminDeleteSpendCap"
},
{
"description": "deleteVolume destroys a volume the board has just proven no PersistentVolume in any\ncluster references. Irreversible, so it snapshots first unless explicitly waived —\nthe snapshot IS the undo.",
"inputSchema": {
"properties": {
"id": {
"description": "ID is the DO volume id, from the path.",
"type": "string"
},
"name": {
"description": "Name is the snapshot name on the snapshot action. Blank gets a deterministic\n\"\u003cvolume\u003e-predelete-\u003cunix\u003e\" so the undo is findable in the DO console.",
"type": "string"
},
"sizeGiB": {
"description": "SizeGiB is the target size on the resize action. A volume only ever grows —\nExpandTo is the verdict that refuses a shrink, so this is not validated here.",
"type": "integer"
},
"snapshot": {
"description": "Snapshot is the snapshot-first switch on DELETE. Anything other than the literal\n\"false\" snapshots before destroying — the snapshot IS the undo, so waiving it is\ndeliberate and explicit.",
"type": "string"
}
},
"type": "object"
},
"name": "adminDeleteVolume"
},
{
"description": "Finance answers GET /v1/admin/finance. It reads the multi-vendor COGS from commerce\n/v1/costs, the DO promo-credit/burn-down treasury view, and the fleet commerce revenue,\nthen hands them to ComputeFinance. SuperAdmin only.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "adminFinance"
},
{
"description": "Backfill carries ONE org's current commerce prepaid balance into the native finance\nwallet — the one-time cutover between the two ledgers.\n\nIt is IDEMPOTENT: the deposit uses the fixed ref \"backfill:\u003corg\u003e\", so re-running it\ncredits the wallet at most once. Safe to retry.\n\nThe pre-migration balance is read from the CO-RESIDENT commerce ledger, not over HTTP:\nthe admin HTTP client dials an unroutable in-process address and would read $0, and a\nphantom zero would silently carry nothing while reporting success. When commerce is\nnot co-resident this fails rather than migrating nothing.",
"inputSchema": {
"properties": {
"org": {
"description": "Org is the tenant to migrate. Required — there is no fleet-wide form of this\ncutover, because each org must be reconciled on its own.",
"type": "string"
}
},
"type": "object"
},
"name": "adminFinanceBackfill"
},
{
"description": "flagsBoard reads the platform control-plane board: every runtime launch/release\nswitch (waitlist, public signup, subsystem activation, gateway limits, network ids)\nwith its LIVE value and where that value came from — a stored definition or the\ncompiled-in default.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "adminFlags"
},
{
"description": "GrantCredit issues a staff credit grant to the org named in the path — a comp, refund\nor promo — through the ONE credit-write path core.ApplyGrant, which validates the\namount against the per-grant cap, checks the org exists, moves the money and records\nthe tamper-evident audit row.\n\nThe credit lands on the account account.Payer resolves, NOT necessarily the org: name\na member of a pooled org and the pool is credited. The receipt echoes the subject so\nthe caller can see which.",
"inputSchema": {
"properties": {
"amountCents": {
"description": "AmountCents is the credit, in whole cents. Must be positive and within the\nper-grant cap.",
"type": "integer"
},
"currency": {
"description": "Currency is the ISO code, lower-cased. Empty means usd.",
"type": "string"
},
"org": {
"description": "Org is the tenant to credit. Required.",
"type": "string"
},
"reason": {
"description": "Reason is the operator's justification, recorded on the audit row.",
"type": "string"
},
"source": {
"description": "Source is the money bucket: \"trial\" (default) for a non-cash comp that is never\nrefundable, or \"prepaid\" for real money. Anything unknown falls back to trial.",
"type": "string"
},
"user": {
"description": "User optionally names a MEMBER to credit, by bare IAM username. Empty credits\nthe org. Which of the two the money actually lands on is decided by\naccount.Payer, not here: a pooled org keeps one balance whatever is named.",
"type": "string"
}
},
"type": "object"
},
"name": "adminGrantCredit"
},
{
"description": "Grants reads the credit-grant ledger across ALL orgs, newest first — who granted what\nto whom, when, and from which money bucket.\n\nIt is a PROJECTION of the tamper-evident audit trail, not a second store: every grant\nis written there as action \"admin.customer.credit\", so this view cannot drift from\nwhat actually happened, and FAILED grants appear too.\n\nA deployment with no local audit store has no history to project, and says so with an\nempty list and a msg rather than an error.",
"inputSchema": {
"properties": {
"limit": {
"description": "Limit caps the rows returned. Default 200.",
"type": "string"
},
"org": {
"description": "Org filters by the ACTOR's org (the staff org that issued the grant), which is\nrarely what a reader wants — the target org is a row field, not a filter.",
"type": "string"
},
"result": {
"description": "Result filters by outcome: \"success\" or \"error\". Empty returns both, which is\nthe point of this view — a refused grant is as interesting as a granted one.",
"type": "string"
}
},
"type": "object"
},
"name": "adminGrants"
},
{
"description": "read serves the whole DigitalOcean infrastructure board: droplets, volumes, DOKS\nclusters and load balancers, each cross-referenced against every cluster's live\nKubernetes state so the board can say what is safe to destroy and what is not.\n\nIt is cached for up to a minute because one read is a fan-out over the DO API plus a\nfull pod/PV listing per cluster. Staleness is never load-bearing: every MUTATION\nre-scans from scratch and ignores this cache.\n\nOnly an unusable DO account is a hard failure. A partial read still produces a board,\nwith the failing source named in sources[] — except for clusters and volumes, which\nthe safety verdict depends on; without those the analysis degrades rather than\nclassifying anything it cannot prove.",
"inputSchema": {
"properties": {
"refresh": {
"description": "Refresh, when present, forces a full re-scan instead of serving the cached\nsnapshot. Every MUTATION re-scans regardless — this is only for the reader.",
"type": "string"
}
},
"type": "object"
},
"name": "adminInfra"
},
{
"description": "Invoices answers GET /v1/admin/invoices.\n\n\tGET /v1/admin/invoices?org=\u0026status=\u0026limit=",
"inputSchema": {
"properties": {
"limit": {
"description": "Limit caps the rows returned. total still reports the full match count.",
"type": "string"
},
"org": {
"description": "Org filters to one tenant, matched exactly.",
"type": "string"
},
"status": {
"description": "Status filters on the invoice's LATEST lifecycle status (paid, open, void, …),\nmatched case-insensitively.",
"type": "string"
}
},
"type": "object"
},
"name": "adminInvoices"
},
{
"description": "IssueGrant issues a credit grant to any org from the operator Grants view, with the\ntarget named in the body. It funnels through the SAME core.ApplyGrant that\nPOST /v1/admin/customers/:org/credit uses, so there is exactly ONE credit-write path\nand one audit trail behind both.",
"inputSchema": {
"properties": {
"amountCents": {
"description": "AmountCents is the credit, in whole cents. Must be positive and within the\nper-grant cap.",
"type": "integer"
},
"currency": {
"description": "Currency is the ISO code, lower-cased. Empty means usd.",
"type": "string"
},
"org": {
"description": "Org is the tenant to credit. Required.",
"type": "string"
},
"reason": {
"description": "Reason is the operator's justification, recorded on the audit row.",
"type": "string"
},
"source": {
"description": "Source is the money bucket: \"trial\" (default) for a non-cash comp that is never\nrefundable, or \"prepaid\" for real money. Anything unknown falls back to trial.",
"type": "string"
},
"user": {
"description": "User optionally names a MEMBER to credit, by bare IAM username. Empty credits\nthe org. Which of the two the money actually lands on is decided by\naccount.Payer, not here: a pooled org keeps one balance whatever is named.",
"type": "string"
}
},
"type": "object"
},
"name": "adminIssueGrant"
},
{
"description": "me answers with the validated operator identity — who the console is signed in as,\nwhich tier they are, and how wide their tenant window is. The fields come from the\nsanitized identity headers the gate just read, so they are authoritative and never\nclient-forgeable; nothing is looked up.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "adminMe"
},
{
"description": "Metrics answers GET /v1/admin/metrics by aggregating commerce.events directly\n(fleet-wide, no per-org fan-out). SuperAdmin only.\n\n\tGET /v1/admin/metrics?window=30d\u0026limit=20",
"inputSchema": {
"properties": {
"limit": {
"description": "Limit caps the top-customers table.",
"type": "string"
},
"window": {
"description": "Window is the movement window the new/churned MRR and the recent feed are\nmeasured over. Anything unrecognised falls back to the board default.",
"type": "string"
}
},
"type": "object"
},
"name": "adminMetrics"
},
{
"description": "moneyBoardHandler answers GET /v1/admin/money.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "adminMoney"
},
{
"description": "o11y is the fleet-wide observability board: LLM usage (requests, tokens, cost,\nerrors, top orgs, top models), trace RED metrics (count, p50/p95/p99 latency in ms,\nerror rate, top services), fleet log volume, and the O11yAI generation rollup — all\naggregated across EVERY tenant, with no org filter applied.\n\nEvery signal degrades INDEPENDENTLY. A table that is absent or errors contributes its\nzero value and the read still succeeds, so the board renders exactly what the\nwarehouse holds rather than failing whole because one of four sources is missing.\nSame when the warehouse is not connected at all: the zero board, never a fabricated\nfleet.",
"inputSchema": {
"properties": {
"range": {
"description": "Range is the lower time bound: 24h, 7d or 30d. Anything else reads as the\nboard's own default.",
"type": "string"
}
},
"type": "object"
},
"name": "adminO11y"
},
{
"description": "orgs lists the tenant directory one row per org, sorted by slug: member count and the\norg's month-to-date spend and credit balance, read live from IAM and commerce.\n\nThe rows are the caller's tenant window, not the fleet: a SuperAdmin gets every org, a\nwhite-label admin only their own subtree. A per-org read that fails degrades THAT row\nto an honest zero — this panel carries no sources[] channel to report freshness on, so\nthe alternative would be a fleet total that silently reads healthy.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "adminOrgs"
},
{
"description": "overview is the Platform Overview tiles: how many orgs and users are in the caller's\ntenant window, the fleet workload counts, and month-to-date spend and credits.\n\nIt ALWAYS answers 200 — a tile board that fails as a whole because one upstream is\ndown is useless. Instead every upstream reports itself in sources[]: ok, degraded, or\nnot-configured. A commerce read that failed for ANY org marks that source degraded,\nbecause the spend/credits totals are then an undercount and must not read healthy.\n\ntokens30d is 0 for the same reason /usage has no series: there is no fleet token\ncounter to read yet.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "adminOverview"
},
{
"description": "products lists the fleet workload registry: every operator App CR across the platform\nnamespaces with its declared vs running image tag, reconciled health/phase and drift\nverdict. Optionally narrowed by kind, tier or env, each an exact match.\n\nThe rows are the SAME observation /v1/platform/fleet renders — read through the in-process\nplatform seam, not a second k8s client — so the two boards can never disagree about what\nthe fleet is. A PaaS plane that is not co-resident yields an honestly empty registry,\nnever a fabricated row.",
"inputSchema": {
"properties": {
"env": {
"description": "Env matches the lifecycle namespace (main|test|dev).",
"type": "string"
},
"kind": {
"description": "Kind matches the operator App CR's declared spec.role (sql|kv|generic|ingress).",
"type": "string"
},
"tier": {
"description": "Tier matches the derived infra grouping (cloud|data|edge|daemon|paas|app).",
"type": "string"
}
},
"type": "object"
},
"name": "adminProducts"
},
{
"description": "getPromo reads the current platform plan promo — the singleton discount offer, e.g.\nthe 50%-off launch promo. Commerce stores it in the reserved platform namespace, so\nthe org sent with the read is the admin org and the service token is what passes\ncommerce's own platform-admin gate.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "adminPromo"
},
{
"description": "ProvidersCredit serves GET /v1/admin/providers/credit — the per-provider upstream\ncredit ledger. SuperAdmin-guarded (see Routes).",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "adminProvidersCredit"
},
{
"description": "ReactivateCustomer restores access for every member of the org, undoing a suspend. It\nreports the same per-user breakdown.",
"inputSchema": {
"properties": {
"org": {
"description": "Org is the tenant slug from the path.",
"type": "string"
}
},
"type": "object"
},
"name": "adminReactivateCustomer"
},
{
"description": "resizeDroplet changes a droplet's plan. Same refusal as delete and for the same\nreason: a DOKS node's size is the node pool's to declare.\n\ndisk=true is a PERMANENT resize — the disk grows and DO can never resize the droplet\nDOWN again. disk=false (the default) changes CPU/RAM only and is reversible. DO\nrequires the droplet to be powered off and applies the change asynchronously, so the\nresponse carries the action to poll, not a completed change.",
"inputSchema": {
"properties": {
"disk": {
"description": "Disk requests a PERMANENT resize that grows the disk. DO can never resize such a\ndroplet down again, so it defaults false — a CPU/RAM-only change, reversible.",
"type": "boolean"
},
"id": {
"description": "ID is the DO droplet id, from the path. Numeric.",
"type": "string"
},
"size": {
"description": "Size is the target DigitalOcean size slug on resize, e.g. \"s-4vcpu-8gb\".",
"type": "string"
}
},
"type": "object"
},
"name": "adminResizeDroplet"
},
{
"description": "expandVolume grows a volume. GROW ONLY — see Volume.ExpandTo for why the other\ndirection is a data migration this board deliberately refuses to run.\n\nThe MECHANISM follows the volume's owner, because there is exactly one way to grow each\nkind completely. A volume a PVC claims is grown by patching the claim: the CSI driver\nthen resizes the DigitalOcean device AND grows the filesystem on it, leaving claim, PV,\ndevice and filesystem all agreeing. Calling DigitalOcean directly for that volume would\ngrow the device while the PV kept declaring the old capacity and the filesystem never\ngrew at all. One operation, one correct mechanism per owner — not two ways to do it.",
"inputSchema": {
"properties": {
"id": {
"description": "ID is the DO volume id, from the path.",
"type": "string"
},
"name": {
"description": "Name is the snapshot name on the snapshot action. Blank gets a deterministic\n\"\u003cvolume\u003e-predelete-\u003cunix\u003e\" so the undo is findable in the DO console.",
"type": "string"
},
"sizeGiB": {
"description": "SizeGiB is the target size on the resize action. A volume only ever grows —\nExpandTo is the verdict that refuses a shrink, so this is not validated here.",
"type": "integer"
},
"snapshot": {
"description": "Snapshot is the snapshot-first switch on DELETE. Anything other than the literal\n\"false\" snapshots before destroying — the snapshot IS the undo, so waiving it is\ndeliberate and explicit.",
"type": "string"
}
},
"type": "object"
},
"name": "adminResizeVolume"
},
{
"description": "Revenue is the fleet money board: total prepaid balances held, total realized spend,\nMRR, ARPU, a per-customer table sorted highest-revenue first, and a real 30-day spend\ntrend from the usage ledger.\n\nORTHOGONAL to /v1/admin/finance, which is the COGS/margin view of what WE pay vendors.\nThis is the customer side: what each customer holds, spends and subscribes to.\n\narpu divides realized spend by PAYING customers, not by all of them — a fleet of free\nsignups must not deflate the number. A customer counts as paying when it has spend or\nMRR.\n\nAn org whose money did not read degrades to honest zeros and marks the commerce source\ndegraded in sources[], so a partial fleet read is visible instead of quietly low.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "adminRevenue"
},
{
"description": "roles lists IAM roles for one owner org, forwarded VERBATIM from IAM's get-roles.",
"inputSchema": {
"properties": {
"owner": {
"description": "Owner is the org whose rows to read. Defaults to the admin org, which owns the\nplatform's roles and applications.",
"type": "string"
},
"p": {
"description": "Page is the 1-based page number. Forwarded only when set — IAM applies its own\ndefault otherwise.",
"type": "string"
},
"pageSize": {
"description": "PageSize is rows per page. Forwarded only when set.",
"type": "string"
}
},
"type": "object"
},
"name": "adminRoles"
},
{
"description": "scaleNodePool sets a node pool's node count — the ONE correct way to change how many\nnodes a DOKS cluster has.\n\nThe response states what the board could NOT prove: DOKS picks which nodes a shrink\nremoves, so no particular pod is shown to survive one. See NodePool.ScaleTo.",
"inputSchema": {
"properties": {
"count": {
"description": "Count is the node count to set.",
"type": "integer"
},
"id": {
"description": "ID is the DOKS cluster id, from the path.",
"type": "string"
},
"pool": {
"description": "Pool is the node pool, from the path. Its DO id or its name — both are unique\nwithin a cluster, and an operator reads the name off the board.",
"type": "string"
}
},
"type": "object"
},
"name": "adminScaleNodePool"
},
{
"description": "services reads the launch board: every hosted service in the registry with its LIVE\nwaitlist mode, evaluated through the flag engine. This is the \"remove the waitlist one\nservice at a time\" view.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "adminServices"
},
{
"description": "setFlag stores or overwrites ONE platform switch's definition and answers with the\nwhole board as it now stands. The flip is hot: this pod applies it immediately and\npeers converge within one evaluation TTL (15s by default), with no redeploy.\n\nThe body reaches the flag engine BYTE-FOR-BYTE — it is the engine's definition\nformat, not this layer's, so a field the engine understands and admin does not must\nstill arrive intact. setFlagIn names the two fields that matter for documentation; it\nis not a filter.\n\nThe write is recorded in the store's activity log against the caller's email.",
"inputSchema": {
"properties": {
"active": {
"description": "Active is the switch itself: true enables the flag for every evaluation.",
"type": "boolean"
},
"filters": {
"description": "Filters is the optional rollout/payload block of a VALUED switch, e.g.\n{\"groups\":[{\"properties\":[],\"rollout_percentage\":100}],\"payloads\":{\"true\":250}}."
},
"key": {
"description": "Key is the switch to write, taken from the path (e.g. \"waitlist.chat\").",
"type": "string"
}
},
"type": "object"
},
"name": "adminSetFlag"
},
{
"description": "putPromo upserts the platform plan promo — the ONE place the offer is configured.\n\nThe body is commerce's own promo contract and is forwarded BYTE-FOR-BYTE, so no field\ncommerce accepts is dropped in transit. promoIn names its documented fields.",
"inputSchema": {
"properties": {
"active": {
"description": "Active is the master switch: false parks the offer without deleting it.",
"type": "boolean"
},
"end": {
"description": "End is when the offer closes (RFC3339).",
"type": "string"
},
"percentOff": {
"description": "PercentOff is the discount, 0-100.",
"type": "integer"
},
"plans": {
"description": "Plans are the plan ids the offer applies to.",
"items": {
"type": "string"
},
"type": "array"
},
"start": {
"description": "Start is when the offer opens (RFC3339).",
"type": "string"
}
},
"type": "object"
},
"name": "adminSetPromo"
},
{
"description": "setServiceMode flips ONE service's waitlist switch — the launch lever. Hot: it takes\neffect on this pod immediately and on peers within one evaluation TTL, with no\nredeploy. An unknown service is a 404, not a silent create; onboarding goes through\nupsertService.",
"inputSchema": {
"properties": {
"service": {
"description": "Service is the slug to flip, taken from the path.",
"type": "string"
},
"waitlistMode": {
"description": "WaitlistMode is the new mode: true gates the service behind the waitlist, false\nopens it. This is the launch lever.",
"type": "boolean"
}
},
"type": "object"
},
"name": "adminSetServiceMode"
},
{
"description": "snapshotVolume takes a point-in-time snapshot of one volume — the undo a delete relies\non, available on its own so an operator can take one before any risky change.\n\nIt re-scans the board first (never the cache) so the volume it snapshots is one that\nexists right now, and audits the outcome either way.",
"inputSchema": {
"properties": {
"id": {
"description": "ID is the DO volume id, from the path.",
"type": "string"
},
"name": {
"description": "Name is the snapshot name on the snapshot action. Blank gets a deterministic\n\"\u003cvolume\u003e-predelete-\u003cunix\u003e\" so the undo is findable in the DO console.",
"type": "string"
},
"sizeGiB": {
"description": "SizeGiB is the target size on the resize action. A volume only ever grows —\nExpandTo is the verdict that refuses a shrink, so this is not validated here.",
"type": "integer"
},
"snapshot": {
"description": "Snapshot is the snapshot-first switch on DELETE. Anything other than the literal\n\"false\" snapshots before destroying — the snapshot IS the undo, so waiving it is\ndeliberate and explicit.",
"type": "string"
}
},
"type": "object"
},
"name": "adminSnapshotVolume"
},
{
"description": "listSpendCaps reads one org's usage caps: its spend alerts plus the derived period\nspend, over/warn state and reset time.\n\nThese are the SAME rows the customer edits in their own console — a platform override\nand a customer budget are one model, not two.",
"inputSchema": {
"properties": {
"id": {
"description": "ID is the cap to edit or remove, from the path. Unused by the list and create ops.",
"type": "string"
},
"org": {
"description": "Org is the tenant to act on. Required for a SuperAdmin — they must name their\ntarget; ignored for a white-label admin, who always acts on their own org.",
"type": "string"
}
},
"type": "object"
},
"name": "adminSpendCaps"
},
{
"description": "Subscriptions answers GET /v1/admin/subscriptions.\n\n\tGET /v1/admin/subscriptions?org=\u0026status=\u0026limit=",
"inputSchema": {
"properties": {
"limit": {
"description": "Limit caps the rows returned. total still reports the full match count.",
"type": "string"
},
"org": {
"description": "Org filters to one tenant, matched exactly.",
"type": "string"
},
"status": {
"description": "Status filters on the subscription's LATEST lifecycle status (active, trialing,\ncanceled, …), matched case-insensitively.",
"type": "string"
}
},
"type": "object"
},
"name": "adminSubscriptions"
},
{
"description": "subsystems answers GET /v1/admin/subsystems. ?range=24h|7d|30d bounds the telemetry\nwindow (default 30d) — the same enum, and the same helpers, as the o11y board.",
"inputSchema": {
"properties": {
"range": {
"description": "Range bounds the telemetry window: 24h, 7d or 30d. Anything else, including\nempty, resolves to the default through the same o11yRange the o11y board uses.",
"type": "string"
}
},
"type": "object"
},
"name": "adminSubsystems"
},
{
"description": "SuspendCustomer cuts off every member of the org: IAM refuses a forbidden user at\nlogin AND at token issuance, so a suspended customer can neither sign in nor mint a\nfresh token. Fully reversible with ReactivateCustomer.\n\nThe result names every user updated and every user that was NOT — a partial failure\nleaves the org in a mixed state and says so instead of reporting a clean success.",
"inputSchema": {
"properties": {
"org": {
"description": "Org is the tenant slug from the path.",
"type": "string"
}
},
"type": "object"
},
"name": "adminSuspendCustomer"
},
{
"description": "syncNow answers the operator's \"Sync now\" button. There is nothing to kick: admin\naggregates LIVE on every read, so the button is just a re-read. It acknowledges\nhonestly with started:true rather than pretending a batch job was queued.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "adminSync"
},
{
"description": "updateSpendCap edits one cap by id — raise or lower the ceiling, flip enforcement. The\nbody is commerce's spend-alert patch contract, forwarded byte-for-byte.",
"inputSchema": {
"properties": {
"id": {
"description": "ID is the cap to edit or remove, from the path. Unused by the list and create ops.",
"type": "string"
},
"org": {
"description": "Org is the tenant to act on. Required for a SuperAdmin — they must name their\ntarget; ignored for a white-label admin, who always acts on their own org.",
"type": "string"
}
},
"type": "object"
},
"name": "adminUpdateSpendCap"
},
{
"description": "upsertService onboards a hosted service, or edits one, so a new host comes under the\nlaunch gate WITHOUT a redeploy. Re-registering an existing service PRESERVES its live\nswitch — editing the hosts of a service that is already open must not silently close\nit again.",
"inputSchema": {
"properties": {
"description": {
"type": "string"
},
"displayName": {
"type": "string"
},
"hosts": {
"items": {
"type": "string"
},
"type": "array"
},
"service": {
"type": "string"
},
"waitlistMode": {
"type": "boolean"
}
},
"type": "object"
},
"name": "adminUpsertService"
},
{
"description": "usage returns the month-to-date money totals: one org's when org names one, else the\nfleet sum across every org a SuperAdmin can see.\n\nseries and byProduct are ALWAYS empty. A daily trend and a per-product split are not\nderivable from the commerce billing API — they live in insights/datastore — so this\nanswers with the honest empty arrays rather than fabricating a shape the console would\nthen chart. Same reason tokens and requests are 0: there is no fleet counter to read.",
"inputSchema": {
"properties": {
"org": {
"description": "Org reads ONE tenant's month-to-date total instead of the fleet sum. Honoured\nfor a SuperAdmin only — a white-label admin always reads their own org.",
"type": "string"
}
},
"type": "object"
},
"name": "adminUsage"
},
{
"description": "UsageFunding splits our upstream AI usage by how it was FUNDED: one row per (provider,\nmodel) over the window, tagged credit (provider grant still remaining), paid (grant\nexhausted) or paid_only (no grant at all).\n\nThe class is resolved at the PROVIDER level from the credit ledger, not per call — the\nper-call split, and the `byo` class, arrive when the metering write stamps a funding\ncolumn on cloud_usage and this can GROUP BY it directly. Until then a provider with\nremaining grant reports all of its usage as credit, which is right in aggregate and\napproximate at the boundary where a grant runs out mid-window.\n\nAn unparseable window falls back to the last 30 days rather than refusing: this is a\ndashboard read, and a typo in a date must not blank the board.",
"inputSchema": {
"properties": {
"from": {
"description": "From is the inclusive start of the window. Unparseable or absent, together with\nTo, falls back to the last 30 days.",
"type": "string"
},
"to": {
"description": "To is the exclusive end of the window.",
"type": "string"
}
},
"type": "object"
},
"name": "adminUsageFunding"
},
{
"description": "users lists the user directory across the caller's tenant window, one page at a time.\ntotal is IAM's REAL total, so the console can page through it.\n\nA SuperAdmin may aim the read at one tenant with org; a white-label admin cannot — for\nthem the owner is hard-pinned to their own org and org is ignored, which is what keeps\nthe directory from becoming a cross-tenant read.",
"inputSchema": {
"properties": {
"org": {
"description": "Org narrows the directory to ONE tenant. Honoured for a SuperAdmin only — a\nwhite-label admin is pinned to their own org and this is ignored.",
"type": "string"
},
"p": {
"description": "Page is the 1-based page number. Defaults to \"1\"; IAM returns zero rows AND a\nzero total when it is unset, so this layer never leaves it empty.",
"type": "string"
},
"pageSize": {
"description": "PageSize is rows per page. Defaults to \"200\", the shared admin page size.",
"type": "string"
},
"q": {
"description": "Query is a free-text filter, matched by IAM as a \"contains\" over the user name.",
"type": "string"
}
},
"type": "object"
},
"name": "adminUsers"
},
{
"description": "waitlist reads one waitlist's leaderboard from the Hanzo waitlist engine — position,\npoints and referral standing per entry — proxied server-authed with the engine secret,\nnever a client credential.\n\nThe engine's payload is forwarded VERBATIM as data; the console normalizes it. When\nthe engine is not configured on this deployment the read still succeeds, with an empty\nobject and a msg saying so, so the panel shows an honest not-wired state instead of an\nerror the operator would chase.",
"inputSchema": {
"properties": {
"page": {
"description": "Page is the 1-based page number.",
"type": "string"
},
"pageSize": {
"description": "PageSize is entries per page.",
"type": "string"
},
"waitlist": {
"description": "Waitlist is the waitlist slug to read (e.g. \"chat\"). The engine decides what an\nempty slug means.",
"type": "string"
}
},
"type": "object"
},
"name": "adminWaitlist"
},
{
"description": "waitlistBoost grants a user waitlist points, moving them up toward the access cutoff.\nThis is the access lever: the cutoff itself does not move, the person does.\n\nIt funnels through the engine's verified grant seam (POST /v1/waitlist/award with\nsource=\"grant\" — the ONE path that honours an explicit points amount) and writes a\ntamper-evident audit row either way, so a FAILED grant is recorded too. The reason\nfield goes only to that row.",
"inputSchema": {
"properties": {
"email": {
"description": "Email identifies the entry to boost. Either this or RefCode is required.",
"type": "string"
},
"points": {
"description": "Points is how many points to award. Must be positive — this seam exists to move\nsomeone UP toward the cutoff.",
"type": "integer"
},
"reason": {
"description": "Reason is the operator's justification. Not sent to the engine; it is recorded on\nthe audit row, which is the point of asking for it.",
"type": "string"
},
"refCode": {
"description": "RefCode identifies the entry by its referral code, when the email is unknown.",
"type": "string"
},
"waitlist": {
"description": "Waitlist is the waitlist slug the grant lands on. Required.",
"type": "string"
}
},
"type": "object"
},
"name": "adminWaitlistBoost"
}
]
+15
View File
@@ -0,0 +1,15 @@
[
{
"description": "WaitlistMode reports whether ONE host is currently gated by the launch waitlist.\nIt resolves the host to the service that governs it and reads that service's\nwaitlist switch, so a guard sitting in front of a hosted surface can decide in one\ncall whether to show the waitlist or the product. It answers for the ONE host\nasked about and never enumerates the registry, which is why it needs no\ncredential. It FAILS OPEN: an unregistered host, an unmounted registry and a store\nfault all answer known=false with mode=false, so a request is never gated pre-boot\nor on a registry fault.",
"inputSchema": {
"properties": {
"host": {
"description": "Host is the host to resolve, e.g. \"chat.hanzo.ai\". Defaults to the request's\nown Host header when omitted, which is what lets a guard running on the\ngoverned host ask about itself with no argument.",
"type": "string"
}
},
"type": "object"
},
"name": "get_v1_flags_waitlist"
}
]
+117
View File
@@ -0,0 +1,117 @@
[
{
"description": "deleteCampaign removes one of the caller org's campaigns and answers 204 with\nno body. It deletes the stored record only: a campaign already launched keeps\nrunning on the ad network, which must be stopped there. An id another org owns\nreads as not found.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "delete_v1_ads_campaigns_id"
},
{
"description": "listCampaigns returns the caller org's ad campaigns, most recently updated\nfirst, optionally narrowed to one lifecycle status. The listing is bounded by\nthe org: another tenant's campaigns are not reachable from here at all.",
"inputSchema": {
"properties": {
"limit": {
"description": "Limit caps how many campaigns come back: default 200, maximum 1000. A\nvalue that is not a positive integer reads as the default.",
"type": "integer"
},
"status": {
"description": "Status filters to one lifecycle state (draft, active, paused, completed).\nEmpty returns every campaign the org has.",
"type": "string"
}
},
"type": "object"
},
"name": "get_v1_ads_campaigns"
},
{
"description": "getCampaign returns one of the caller org's campaigns. An id another org owns\nreads as not found, so the response cannot confirm that it exists.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "get_v1_ads_campaigns_id"
},
{
"description": "summary rolls the caller org's ad campaigns up into four numbers: how many\ncampaigns exist, how many are active, and the summed budget and spend across\nall of them. Budget and spend are MINOR units (cents), the same units the\ncampaign rows carry. It counts only this org's campaigns.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "get_v1_ads_summary"
},
{
"description": "createCampaign registers a new ad campaign for the caller's org and answers\n201 with the stored row. It only records the campaign — nothing is sent to the\nad network until POST /v1/ads/campaigns/{id}/launch runs it. The org is\nstamped by the server from the validated principal, so a body can never place\na campaign in another tenant.",
"inputSchema": {
"properties": {
"account": {
"description": "Account is the provider ad-account this campaign runs on (Meta act_\u003cid\u003e). Optional.",
"type": "string"
},
"budget": {
"description": "Budget is the campaign budget in MINOR units (cents). Negative values clamp to 0.",
"type": "integer"
},
"name": {
"description": "Name is the campaign's display label. Required; trimmed and bounded to 1024 bytes.",
"type": "string"
},
"objective": {
"description": "Objective is the campaign goal as the provider names it. Optional, bounded to 1024 bytes.",
"type": "string"
},
"platform": {
"description": "Platform is the ad network: meta, google, tiktok or x. Empty defaults to meta.",
"type": "string"
},
"spend": {
"description": "Spend is the amount spent so far in MINOR units (cents). Negative values clamp to 0.",
"type": "integer"
},
"status": {
"description": "Status is the lifecycle state: draft, active, paused or completed. Empty defaults to draft.",
"type": "string"
}
},
"type": "object"
},
"name": "post_v1_ads_campaigns"
},
{
"description": "updateCampaign replaces the user-owned fields of one of the caller org's\ncampaigns and answers the stored row. It is a full replace, not a patch: every\nfield is written from the request, so an omitted one is cleared. externalId is\nlaunch-owned and is never touched here, so editing a campaign cannot break its\nlink to a live provider execution.",
"inputSchema": {
"properties": {
"account": {
"description": "Account is the provider ad-account this campaign runs on (Meta act_\u003cid\u003e). Optional.",
"type": "string"
},
"budget": {
"description": "Budget is the campaign budget in MINOR units (cents). Negative values clamp to 0.",
"type": "integer"
},
"name": {
"description": "Name is the campaign's display label. Required; trimmed and bounded to 1024 bytes.",
"type": "string"
},
"objective": {
"description": "Objective is the campaign goal as the provider names it. Optional, bounded to 1024 bytes.",
"type": "string"
},
"platform": {
"description": "Platform is the ad network: meta, google, tiktok or x. Empty defaults to meta.",
"type": "string"
},
"spend": {
"description": "Spend is the amount spent so far in MINOR units (cents). Negative values clamp to 0.",
"type": "integer"
},
"status": {
"description": "Status is the lifecycle state: draft, active, paused or completed. Empty defaults to draft.",
"type": "string"
}
},
"type": "object"
},
"name": "put_v1_ads_campaigns_id"
}
]
+1
View File
@@ -0,0 +1 @@
[]
+1
View File
@@ -0,0 +1 @@
[]
+656
View File
@@ -0,0 +1,656 @@
[
{
"description": "DeleteAgent removes an agent and every run recorded against it. Answers 204.",
"inputSchema": {
"properties": {
"ref": {
"description": "Ref is the agent's public id (the agent_… handle create and list return) or\nits org-unique name, from the path. Either resolves the same agent.",
"type": "string"
}
},
"type": "object"
},
"name": "delete_v1_agents_ref"
},
{
"description": "DeleteTarget deregisters one machine. Only its owner, or an org admin, may\nremove it; an unknown id, a cross-org id and a machine owned by someone else\nall answer the same not-found, so a probe learns nothing about what exists.",
"inputSchema": {
"properties": {
"id": {
"description": "ID is the target to act on, from the path.",
"type": "string"
}
},
"type": "object"
},
"name": "delete_v1_agents_targets_id"
},
{
"description": "ListAgents returns every agent defined in the caller's org, each with the\nnumber of runs recorded against it.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "get_v1_agents"
},
{
"description": "AgentActivity serves the org-wide recent-activity feed. Events are REAL: each\nrecorded run is an invoked (ok) or failed (error) event; each agent's own\ncreate/update timestamps are created/updated events. Merged, newest first,\ncapped. Nothing is invented — an org with no agents and no runs gets [].",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "get_v1_agents_activity"
},
{
"description": "ListBuilds returns the public index of every published build, most recently\nupdated first, so a gallery can link straight to the story behind each product.\nPUBLIC, no tenancy: publishing is the author's act, and only published root\nsessions appear here.",
"inputSchema": {
"properties": {
"limit": {
"description": "Limit caps the page. Absent, zero or over 500 reads as 100.",
"type": "integer"
}
},
"type": "object"
},
"name": "get_v1_agents_builds"
},
{
"description": "ReadBuild returns the readable build of one product: the agent session that\nproduced it, turn by turn — the prompts, the reasoning, the commits each turn\nproduced — plus the exact `git log` that re-derives every commit binding from\ngit itself, so nothing here has to be taken on trust.\n\nPUBLIC, no tenancy: it answers only for a session its author explicitly\npublished, which is what makes it safe to be anonymous. An unpublished session\nis invisible here no matter who asks; its owner reads it through the org-scoped\n/v1/agents/sessions routes, which need a validated principal.",
"inputSchema": {
"properties": {
"org": {
"description": "Org is the org that published the build, from the path.",
"type": "string"
},
"project": {
"description": "Project is the product's slug, from the path.",
"type": "string"
}
},
"type": "object"
},
"name": "get_v1_agents_builds_org_project"
},
{
"description": "AgentMetrics serves the invocations-over-time histogram for the org's Agents\ndashboard. Every point is a REAL count of recorded runs in that time bucket —\none series line per agent that ran in the window. The Resource Usage rollup is\nall-null because this store meters no CPU/memory/storage/cost; the console\nrenders those as \"—\" rather than a fabricated figure. No runs =\u003e empty series\n(an honest \"not connected / no activity yet\"), never a synthesized trend.",
"inputSchema": {
"properties": {
"range": {
"description": "Range is the window to bucket: 24H, 7D or 30D. Anything else reads as 30D.",
"type": "string"
}
},
"type": "object"
},
"name": "get_v1_agents_metrics"
},
{
"description": "GetAgent returns one agent with its system prompt and its 20 most recent runs.\nThe ref is the agent's public id or its org-unique name — a created agent is\nimmediately gettable by whatever create handed back.",
"inputSchema": {
"properties": {
"ref": {
"description": "Ref is the agent's public id (the agent_… handle create and list return) or\nits org-unique name, from the path. Either resolves the same agent.",
"type": "string"
}
},
"type": "object"
},
"name": "get_v1_agents_ref"
},
{
"description": "ListAgentRuns returns one agent's execution history, newest first — each run's\ninput, its output or its error, and how long it took. Every row is a run that\nactually happened.",
"inputSchema": {
"properties": {
"limit": {
"description": "Limit caps how many runs come back, newest first. Absent, zero or out of\nrange (1..200) reads as 50.",
"type": "integer"
},
"ref": {
"description": "Ref is the agent's public id or its org-unique name, from the path.",
"type": "string"
}
},
"type": "object"
},
"name": "get_v1_agents_ref_runs"
},
{
"description": "ListSessions returns the caller org's live sessions, newest first — each with\nits event count, its direct-child count and a one-line preview of its latest\nevent. With no filter it returns ROOT sessions only, so a dashboard shows one\nrow per flow rather than one per subagent; ?root= or ?parent= descends.",
"inputSchema": {
"properties": {
"limit": {
"description": "Limit caps the page. Absent, zero or over 500 reads as 100.",
"type": "integer"
},
"parent": {
"description": "Parent scopes the page to the direct children of one session. Ignored when\nroot is set; with neither, only ROOT sessions come back.",
"type": "string"
},
"project": {
"description": "Project filters to the sessions tagged with one product slug.",
"type": "string"
},
"root": {
"description": "Root scopes the page to one subagent tree (its root session id).",
"type": "string"
},
"status": {
"description": "Status filters to running, paused, done or error.",
"type": "string"
}
},
"type": "object"
},
"name": "get_v1_agents_sessions"
},
{
"description": "GetSession returns one session with its direct child sessions and its 50 most\nrecent events, oldest of those first.",
"inputSchema": {
"properties": {
"id": {
"description": "ID is the session to act on, from the path.",
"type": "string"
}
},
"type": "object"
},
"name": "get_v1_agents_sessions_id"
},
{
"description": "DrainSessionControl returns the steering commands (pause/resume/stop/message)\nrecorded against the caller's own session that are newer than the cursor,\noldest first, with the cursor to poll from next. It is how a locally started\n`hanzo code` session — which is not task-backed, so nothing forwards its\ncommands to an execution engine — consumes what the dashboard posted. Read-only\nand bounded at 200 per poll, so a steady poll is cheap and an applied command is\nnever redelivered.",
"inputSchema": {
"properties": {
"after": {
"description": "After is the last seq this poller applied; only commands newer than it come\nback. Absent or negative reads as 0, which drains from the beginning.",
"type": "integer"
},
"id": {
"description": "ID is the session whose commands are being drained, from the path.",
"type": "string"
}
},
"type": "object"
},
"name": "get_v1_agents_sessions_id_control"
},
{
"description": "SessionTree returns the subagent-flow graph rooted at this session: the session,\nits children, their children, each node carrying its own event count. One\nindexed read pulls the whole flow (every node of a flow shares a root id), so\nthe shape is assembled in memory rather than by walking the store per node.",
"inputSchema": {
"properties": {
"id": {
"description": "ID is the session to act on, from the path.",
"type": "string"
}
},
"type": "object"
},
"name": "get_v1_agents_sessions_id_tree"
},
{
"description": "ListTargets returns every machine registered to the caller's org, newest\nfirst, each with its live session load.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "get_v1_agents_targets"
},
{
"description": "GetTarget returns one registered machine, with its live session load.",
"inputSchema": {
"properties": {
"id": {
"description": "ID is the target to act on, from the path.",
"type": "string"
}
},
"type": "object"
},
"name": "get_v1_agents_targets_id"
},
{
"description": "UpdateAgent changes an agent in place. Every field is optional; a field the\nrequest omits keeps its stored value. The resulting mode+schedule are\nre-validated together, so a partial update can never leave a long-running\nagent without the cron the scheduler needs to fire it, and a transition INTO\nlong-running counts against the per-org cap on scheduled agents.",
"inputSchema": {
"properties": {
"computeRef": {
"type": "string"
},
"description": {
"type": "string"
},
"executionMode": {
"type": "string"
},
"instructions": {
"type": "string"
},
"model": {
"type": "string"
},
"ref": {
"description": "Ref is the agent to update — its public id or org-unique name, from the path.",
"type": "string"
},
"schedule": {
"type": "string"
},
"serviceAccountId": {
"type": "string"
},
"tools": {
"items": {
"type": "string"
},
"type": "array"
}
},
"type": "object"
},
"name": "patch_v1_agents_ref"
},
{
"description": "PatchSession updates a session's surface-owned truth: its status, its title,\nthe run-target it is dispatched to, and the product it built plus whether that\nbuild's story is public. A FINISHED session stays finished — reopening a\ndone/error run would fabricate liveness — and publishing is refused unless the\nsession names the project it built, because the public build route is keyed on\n(org, project).",
"inputSchema": {
"properties": {
"id": {
"description": "ID is the session to update, from the path.",
"type": "string"
},
"project": {
"description": "Project tags the product this session built; Published is the author's\ndecision to let anyone read the story (provenance.go). Both are pointers so\n\"absent\" and \"cleared\" are different requests.",
"type": "string"
},
"published": {
"type": "boolean"
},
"status": {
"type": "string"
},
"target": {
"description": "Target re-dispatches a session to a run-target (the #48 association). \"\" detaches.",
"type": "string"
},
"title": {
"type": "string"
}
},
"type": "object"
},
"name": "patch_v1_agents_sessions_id"
},
{
"description": "PatchTarget updates one machine in place. Every field is optional; a field the\nrequest omits is left alone. A metrics patch IS a heartbeat — the server stamps\nits own clock, so a client can neither forge nor backdate staleness.",
"inputSchema": {
"$defs": {
"GPU": {
"properties": {
"memory": {
"description": "VRAM bytes, 0 = unknown",
"type": "integer"
},
"model": {
"description": "\"GB10\", \"8060S\", \"RTX 4090\"",
"type": "string"
},
"vendor": {
"description": "nvidia | amd | apple | intel | ...",
"type": "string"
}
},
"type": "object"
},
"Metrics": {
"properties": {
"at": {
"description": "unix seconds, server-stamped",
"type": "integer"
},
"gpuUtil": {
"description": "0..1 aggregate utilization",
"type": "number"
},
"load1": {
"type": "number"
},
"load15": {
"type": "number"
},
"load5": {
"type": "number"
},
"memFree": {
"description": "bytes",
"type": "integer"
},
"memUsed": {
"description": "bytes",
"type": "integer"
}
},
"type": "object"
},
"Spec": {
"properties": {
"arch": {
"description": "amd64 | arm64 | ...",
"type": "string"
},
"cpus": {
"description": "logical cores",
"type": "integer"
},
"gpus": {
"items": {
"$ref": "#/$defs/GPU"
},
"type": "array"
},
"memory": {
"description": "total RAM, bytes",
"type": "integer"
},
"os": {
"description": "linux | darwin | windows",
"type": "string"
}
},
"type": "object"
}
},
"properties": {
"capacity": {
"type": "string"
},
"host": {
"type": "string"
},
"id": {
"description": "ID is the target to update, from the path.",
"type": "string"
},
"kind": {
"type": "string"
},
"label": {
"type": "string"
},
"metrics": {
"$ref": "#/$defs/Metrics",
"description": "present =\u003e a heartbeat; the server stamps its time"
},
"spec": {
"$ref": "#/$defs/Spec"
},
"status": {
"type": "string"
}
},
"type": "object"
},
"name": "patch_v1_agents_targets_id"
},
{
"description": "CreateAgent defines an agent in the caller's org: a model, a system prompt\n(instructions) and a set of tool names. The name must be unique in the org and\nmatch ^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$. An omitted model takes the\ndeployment's configured default; a named one is checked against the gateway's\nserved catalog, so a model this deployment never serves is refused here rather\nthan failing at run time. A long-running agent must carry a 5-field cron\nschedule (the scheduler would otherwise never fire it) and counts against a\nper-org cap on scheduled agents.",
"inputSchema": {
"properties": {
"computeRef": {
"type": "string"
},
"description": {
"type": "string"
},
"executionMode": {
"type": "string"
},
"instructions": {
"type": "string"
},
"model": {
"type": "string"
},
"name": {
"type": "string"
},
"schedule": {
"type": "string"
},
"serviceAccountId": {
"type": "string"
},
"tools": {
"items": {
"type": "string"
},
"type": "array"
}
},
"type": "object"
},
"name": "post_v1_agents"
},
{
"description": "RegisterSession opens a live agent session in the caller's org — the row every\nsurface (the CLI's outer agent, hanzo.bot, the console, chat) hangs its\nactivity off. A session with a parentSessionId becomes a subagent of that\nsession and inherits its root, so one flow is one tree; without one it is\nitself a root. Registering with a terminal status records a session that has\nalready finished.",
"inputSchema": {
"properties": {
"account": {
"type": "string"
},
"actor": {
"type": "string"
},
"agent": {
"type": "string"
},
"cwd": {
"type": "string"
},
"host": {
"description": "Execution context — where this session runs (all optional).",
"type": "string"
},
"parentSessionId": {
"type": "string"
},
"project": {
"description": "The readable build (provenance.go): which product this session builds, and\nwhether its story may be read by the world.",
"type": "string"
},
"provider": {
"description": "Account tag — the linked AI account this session ran under (login manager).",
"type": "string"
},
"published": {
"type": "boolean"
},
"repo": {
"type": "string"
},
"status": {
"type": "string"
},
"target": {
"type": "string"
},
"taskRunId": {
"type": "string"
},
"taskWorkflowId": {
"type": "string"
},
"title": {
"type": "string"
}
},
"type": "object"
},
"name": "post_v1_agents_sessions"
},
{
"description": "RegisterTarget registers a machine as an agent target, or re-links one that is\nalready registered. Re-linking is idempotent and keyed on org+host+owner, so a\nmachine that reconnects refreshes its own row rather than piling up duplicates;\nit answers 200, while a first registration answers 201.",
"inputSchema": {
"$defs": {
"GPU": {
"properties": {
"memory": {
"description": "VRAM bytes, 0 = unknown",
"type": "integer"
},
"model": {
"description": "\"GB10\", \"8060S\", \"RTX 4090\"",
"type": "string"
},
"vendor": {
"description": "nvidia | amd | apple | intel | ...",
"type": "string"
}
},
"type": "object"
},
"Metrics": {
"properties": {
"at": {
"description": "unix seconds, server-stamped",
"type": "integer"
},
"gpuUtil": {
"description": "0..1 aggregate utilization",
"type": "number"
},
"load1": {
"type": "number"
},
"load15": {
"type": "number"
},
"load5": {
"type": "number"
},
"memFree": {
"description": "bytes",
"type": "integer"
},
"memUsed": {
"description": "bytes",
"type": "integer"
}
},
"type": "object"
},
"Spec": {
"properties": {
"arch": {
"description": "amd64 | arm64 | ...",
"type": "string"
},
"cpus": {
"description": "logical cores",
"type": "integer"
},
"gpus": {
"items": {
"$ref": "#/$defs/GPU"
},
"type": "array"
},
"memory": {
"description": "total RAM, bytes",
"type": "integer"
},
"os": {
"description": "linux | darwin | windows",
"type": "string"
}
},
"type": "object"
}
},
"properties": {
"capacity": {
"type": "string"
},
"host": {
"type": "string"
},
"kind": {
"type": "string"
},
"label": {
"type": "string"
},
"metrics": {
"$ref": "#/$defs/Metrics"
},
"spec": {
"$ref": "#/$defs/Spec"
},
"status": {
"type": "string"
}
},
"type": "object"
},
"name": "post_v1_agents_targets"
},
{
"description": "ClaimRoutedRun is the machine's long poll for work: it authenticates the\ndaemon, stamps the liveness the dispatch gate reads (the poll IS the proof a\nrunner is listening), and waits up to 25 seconds for the next run addressed to\nTHIS machine. It answers the run when one arrives and 204 with no body when the\nwindow elapses, on which the daemon re-polls immediately.\n\nTWO independent proofs are required and both fail closed to the same 403: the\ncaller must own this machine (or be an org admin) AND present its claim key in\nX-Target-Key. A run offered to one machine is unreachable from another's claim.",
"inputSchema": {
"properties": {
"id": {
"description": "ID is the target to act on, from the path.",
"type": "string"
}
},
"type": "object"
},
"name": "post_v1_agents_targets_id_claim"
},
{
"description": "MintTargetClaimKey mints (or rotates) the claim key a `hanzo code --serve`\ndaemon presents to claim work for this machine, and returns it ONCE: only its\nSHA-256 hash is stored. Rotating supersedes any prior daemon, so only the\nmachine's owner — or an org admin — may call it; every other caller gets the\nsame not-found an unknown id gets, and learns nothing about what exists.",
"inputSchema": {
"properties": {
"id": {
"description": "ID is the target to act on, from the path.",
"type": "string"
}
},
"type": "object"
},
"name": "post_v1_agents_targets_id_claim-key"
},
{
"description": "ReportRoutedRun completes a claimed run: it delivers the terminal result to the\nrun's durable owner, which is what lets that workflow finish. Scoped to (org,\ntarget, run) and claim-key-authenticated, so a machine can only ever report a\nrun it legitimately holds. Idempotent — a report for an unknown or\nalready-finished run answers delivered:false rather than failing, because the\nsession's terminal state was already set by the machine's own stream.",
"inputSchema": {
"properties": {
"branch": {
"description": "Branch, CommitSha and Diffstat describe what the run produced; Error is the\nfailure when OK is false. Each is clamped, never rejected.",
"type": "string"
},
"changed": {
"type": "boolean"
},
"commitSha": {
"type": "string"
},
"diffstat": {
"type": "string"
},
"error": {
"type": "string"
},
"id": {
"description": "ID is the machine reporting, from the path.",
"type": "string"
},
"ok": {
"description": "OK is whether the run succeeded; Changed whether it produced any commit.",
"type": "boolean"
},
"runId": {
"description": "RunID is the routed run being completed, from the path.",
"type": "string"
}
},
"type": "object"
},
"name": "post_v1_agents_targets_id_runs_runId_report"
}
]
+1
View File
@@ -0,0 +1 @@
[]
+1
View File
@@ -0,0 +1 @@
[]
+103
View File
@@ -0,0 +1,103 @@
[
{
"description": "Overview returns the caller org's analytics KPIs for one time window. Three lenses\nover one warehouse: llm is the live per-org LLM usage ledger (requests, tokens,\nspend, models, providers, errors) and is always real; web (pageviews, visitors,\nsessions) and commerce (orders, revenue, AOV) read the product-event table and\nreport available=false rather than fabricating zeros when it holds nothing yet.\n\nThe org is the validated principal's — never a parameter — so a caller can only\never read its own tenant. 403 without a validated bearer, 400 on an unknown range,\n503 when the warehouse is unreachable.",
"inputSchema": {
"properties": {
"end": {
"description": "End is the exclusive upper bound of a custom window, RFC3339. Requires start.",
"type": "string"
},
"range": {
"description": "Range is a relative window: 24h, 7d or 30d. Default 24h. Ignored when both\nstart and end are given. An unknown value is a 400.",
"type": "string"
},
"start": {
"description": "Start is the inclusive lower bound of a custom window, RFC3339. Requires end.",
"type": "string"
}
},
"type": "object"
},
"name": "get_v1_analytics_overview"
},
{
"description": "Timeseries returns the caller org's LLM usage over time as an evenly-spaced series.\nOne point per hour or per day — the bucket the window implies, 24h giving hours and\n7d/30d giving days — carrying requests, total tokens and spend in cents. Empty\nbuckets are filled with zeros so a client charts a continuous line.\n\nThe org is the validated principal's — never a parameter. 403 without a validated\nbearer, 400 on an unknown range, 503 when the warehouse is unreachable.",
"inputSchema": {
"properties": {
"end": {
"description": "End is the exclusive upper bound of a custom window, RFC3339. Requires start.",
"type": "string"
},
"range": {
"description": "Range is a relative window: 24h, 7d or 30d. Default 24h. Ignored when both\nstart and end are given. An unknown value is a 400.",
"type": "string"
},
"start": {
"description": "Start is the inclusive lower bound of a custom window, RFC3339. Requires end.",
"type": "string"
}
},
"type": "object"
},
"name": "get_v1_analytics_timeseries"
},
{
"description": "Top returns the caller org's ranked lenses for one window, five of them at once.\nmodels ranks LLM models by spend and is always real; products ranks commerce orders\nby revenue; topPages ranks requested paths, topReferrers the external referrer\ndomains (\"(direct)\" for a missing or same-origin one) and topSources the utm_source\ncampaigns (\"(none)\" when absent), each by pageviews. Every lens carries each row's\nshare of the in-window total, so a top-N honestly shows the long tail.\n\nThe four event lenses report available=false rather than fabricating zeros when the\nproduct-event table holds nothing yet. The org is the validated principal's — never\na parameter. 403 without a validated bearer, 400 on an unknown range, 503 when the\nwarehouse is unreachable.",
"inputSchema": {
"properties": {
"end": {
"description": "End is the exclusive upper bound of a custom window, RFC3339. Requires start.",
"type": "string"
},
"limit": {
"description": "Limit bounds every ranked lens in the response. Default 10, maximum 100; a\nvalue at or below zero, or one that is not a number, takes the default.",
"type": "integer"
},
"range": {
"description": "Range is a relative window: 24h, 7d or 30d. Default 24h. Ignored when both\nstart and end are given. An unknown value is a 400.",
"type": "string"
},
"start": {
"description": "Start is the inclusive lower bound of a custom window, RFC3339. Requires end.",
"type": "string"
}
},
"type": "object"
},
"name": "get_v1_analytics_top"
},
{
"description": "Errors returns the caller org's most recently captured errors, newest first. The\nerror-tracking read view over the same table the capture doors write: only rows\nstored as type 'error', each with its captured exception lifted out of the property\nbag as a first-class field.\n\nThe org is the validated principal's — never a parameter — and this read requires a\nreal bearer, NEVER the write-only publishable key: pk- can attribute a write and can\nread nothing. 403 without a validated bearer, 503 when the warehouse is unreachable.",
"inputSchema": {
"properties": {
"limit": {
"description": "Limit is how many rows to return, newest first. Default 50, maximum 200; a\nvalue at or below zero, or one that is not a number, takes the default.",
"type": "integer"
}
},
"type": "object"
},
"name": "get_v1_errors"
},
{
"description": "InsightsEvents returns the caller org's most recent product events, newest first.\nThe console's raw-event view over the same table the capture doors write: one row\nper stored event, with the caller's own property bag returned verbatim.\n\nThe org is the validated principal's — never a parameter — and a read requires a\nreal bearer, never the write-only publishable key. 403 without a validated bearer,\n503 when the warehouse is unreachable.",
"inputSchema": {
"properties": {
"limit": {
"description": "Limit is how many rows to return, newest first. Default 50, maximum 200; a\nvalue at or below zero, or one that is not a number, takes the default.",
"type": "integer"
}
},
"type": "object"
},
"name": "get_v1_insights_events"
},
{
"description": "InsightsHealth reports that the unified insights surface is serving. It reads no\ntenant data and consults no dependency, so it answers 200 unconditionally and needs\nno principal — liveness must be probe-able. The warehouse-connectivity probe is a\ndifferent question and lives at GET /v1/analytics/health.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "get_v1_insights_health"
}
]
+1
View File
@@ -0,0 +1 @@
[]
+47
View File
@@ -0,0 +1,47 @@
[
{
"description": "List reads the caller's OWN org audit trail, newest first, with the total the\nfilter matched so a console can page it.\n\nEvery filter is optional and applies WITHIN the caller's org — the org itself is\nthe validated principal's and can never be widened by a request. Fails closed:\nan absent principal is a true \"not signed in\" (401), and a deployment with no\nlocal tamper-evident store answers an honest 501 rather than silently serving\nsomebody else's trail.",
"inputSchema": {
"properties": {
"action": {
"description": "Action narrows it to one action name, e.g. \"machine.create\".",
"type": "string"
},
"p": {
"description": "Page is the 1-based page number, driving the offset. Anything below 2 reads\nthe first page.",
"type": "string"
},
"pageSize": {
"description": "PageSize is rows per page, default 100. A value that is not a positive\ninteger falls back to the default.",
"type": "string"
},
"resource": {
"description": "Resource narrows it to one resource TYPE, e.g. \"apikey\".",
"type": "string"
},
"resourceId": {
"description": "ResourceID narrows it to one resource instance.",
"type": "string"
},
"result": {
"description": "Result narrows it to one outcome: \"success\", \"deny\" or \"error\".",
"type": "string"
},
"since": {
"description": "Since is the inclusive lower time bound, RFC3339. An unparseable value is\nignored rather than refused — one malformed filter must not hide the trail.",
"type": "string"
},
"sub": {
"description": "Sub narrows the trail to one actor — the validated subject that made the\nrequest. Blank means every actor in the org.",
"type": "string"
},
"until": {
"description": "Until is the upper time bound, RFC3339, with the same tolerance.",
"type": "string"
}
},
"type": "object"
},
"name": "get_v1_audit"
}
]
+167
View File
@@ -0,0 +1,167 @@
[
{
"description": "ListAuthors returns the platform's whole author program — every org's author\nrecord, not the caller's — with each one's repository and deploy counts and a\nfleet roll-up of the money accrued, pending and paid.\n\nIt is a Hanzo platform operation: a caller who is not a SuperAdmin gets 403. It\nexposes the owning org of each author, which no tenant-facing read ever does.",
"inputSchema": {
"properties": {
"limit": {
"description": "Limit bounds the page. 0 or less means the default of 500; anything above\n1000 is clamped to 1000.",
"type": "integer"
}
},
"type": "object"
},
"name": "get_v1_admin_authors"
},
{
"description": "AuthorRoyaltyBasis returns the audit trail behind ONE author's royalty — the same\npayload the author reads at /v1/authors/basis, from the same builder, so support\nsees exactly what the author sees rather than a parallel view free to drift.\n\nThe data object carries: id, status, asOf, shareBps, platformShareBps,\ndefaultShareBps, shareSource, settlesTo, method (the formula, the rate card and the\nsizing), ledger (every row with its spend, the share applied then, the platform's\nmatching half, whether it satisfies the formula and the attribution edges that\nexplain it), reconciliation (does the ledger foot to the balance) and window (what\nslice was actually returned) — plus period when one was requested.\n\nA Hanzo platform operation: a caller who is not a SuperAdmin gets 403.",
"inputSchema": {
"properties": {
"id": {
"description": "ID is the author record's handle, from the path.",
"type": "string"
},
"period": {
"description": "Period is the UTC accrual month, YYYY-MM. Empty means every period; any other\nshape is refused with 400.",
"type": "string"
}
},
"type": "object"
},
"name": "get_v1_admin_authors_id_basis"
},
{
"description": "MyAuthorProgram returns the caller's author-program dashboard: enrolment status,\nlinked forge login, verified repositories and owner-wide claims, recorded deploys,\naccrued / pending / paid royalty, and the payout history.\n\nIt answers ONE OF TWO SHAPES from this address. An org that has never connected\ngets {\"isAuthor\": false, \"defaultShareBps\", \"badgeBase\"} — an honest \"not enrolled\"\nrather than a 404, so the console can render the connect form. An enrolled org gets\nthe dashboard: isAuthor, id, status, githubLogin, verified, verifyCode, verifyFile,\nverifySnippet, shareBps, badgeBase, repos, orgs, deploys, accruedCents,\npendingCents, paidCents, payouts and ledger.\n\nFor an APPROVED author this read ALSO runs the accrual sweep opportunistically, so\nthe dashboard is self-updating. That is why the royalty AUDIT lives at its own\naddress: an audit must not move the money it is auditing.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "get_v1_authors"
},
{
"description": "MyRoyaltyBasis returns the AUDIT TRAIL behind the caller's own royalty: every\nledger row with the spend it was computed from, the share applied at the time, the\nplatform's matching half, whether each row satisfies the formula, and the\nattribution edges that already existed when the row was written.\n\nIt answers ONE OF TWO SHAPES. An org that has never connected gets\n{\"isAuthor\": false, \"defaultShareBps\"} — never a 404, which would answer \"is this\norg an author?\" for anyone who asked. An enrolled org gets the basis: isAuthor, id,\nstatus, asOf, shareBps, platformShareBps, defaultShareBps, shareSource, settlesTo,\nmethod (the formula, the rate card and the sizing), ledger, reconciliation, window,\nand period when one was requested.\n\nThis read NEVER sweeps, and that is the point of it being a separate address from\nthe dashboard: an audit must not move the money it is auditing, so calling it N\ntimes leaves the balances and the ledger byte-identical.",
"inputSchema": {
"properties": {
"period": {
"description": "Period is the UTC accrual month, YYYY-MM. Empty means every period; any other\nshape is refused with 400, because the period is echoed back and used as a SQL\nfilter and is only ever accepted in the one form the accrual latch mints.",
"type": "string"
}
},
"type": "object"
},
"name": "get_v1_authors_basis"
},
{
"description": "ApproveAuthor admits one author to EARNING, optionally on a negotiated royalty\nshare. Until this runs, a connected author accrues nothing however many verified\nrepositories they have.\n\nA share override applies from here forward only — existing ledger rows keep the\nshare that was applied when they were written, because a rate change must never\nrewrite what was already owed.\n\nA Hanzo platform operation: a caller who is not a SuperAdmin gets 403.",
"inputSchema": {
"properties": {
"id": {
"description": "ID is the author to approve, from the path.",
"type": "string"
},
"shareBps": {
"description": "ShareBps overrides this author's royalty share, in basis points (010000).\n0 keeps the platform default. A share change never rewrites history: existing\nledger rows keep the share that was applied when they were written.",
"type": "integer"
}
},
"type": "object"
},
"name": "post_v1_admin_authors_id_approve"
},
{
"description": "PayAuthor records a payout of accrued royalty and settles it.\n\nThe amount is RESERVED against the author's pending royalty atomically before\nanything is paid, so a payout can never exceed what is owed even under concurrent\ncalls. An external author's payout is then BACKED against the platform reserve\nfund — a second, independent guard — and refused with 402 if the reserve cannot\ncover it, with the reservation voided. A \"credits\" method issues the actual wallet\ngrant after both guards; a cash method is record-only. A first-party (treasury)\nauthor's royalty is realized into Hanzo's own reserve instead of an external\nwallet, and every payout row discloses which of the three it was.\n\nA Hanzo platform operation: a caller who is not a SuperAdmin gets 403.",
"inputSchema": {
"properties": {
"amountCents": {
"description": "AmountCents is how much to pay, in cents. Must be positive and can never\nexceed the author's pending royalty (accrued minus paid).",
"type": "integer"
},
"id": {
"description": "ID is the author to pay, from the path.",
"type": "string"
},
"method": {
"description": "Method is how it settles: \"credits\" issues a grant into the author's wallet;\nwire, paypal and the like are record-only. Required.",
"type": "string"
},
"reference": {
"description": "Reference is the operator's external reference for a cash settlement — a wire\nconfirmation, a PayPal transaction id.",
"type": "string"
}
},
"type": "object"
},
"name": "post_v1_admin_authors_id_payout"
},
{
"description": "SuspendAuthor stops one author earning. Their record, verified claims and ledger\nare untouched — suspension halts future accrual, it does not erase what was already\nowed, and it does not delete the evidence behind it.\n\nA Hanzo platform operation: a caller who is not a SuperAdmin gets 403.",
"inputSchema": {
"properties": {
"id": {
"description": "ID is the author record's handle, \"aut_\"-prefixed.",
"type": "string"
}
},
"type": "object"
},
"name": "post_v1_admin_authors_id_suspend"
},
{
"description": "SweepAuthorRoyalty runs the accrual sweep across every approved author: for each of\ntheir deploying orgs it computes this period's royalty from that org's metered\nspend and latches it at most once per period.\n\nIt is an OVERRIDE, not the mechanism: a background scheduler runs the same sweep on\nits own, and every author's dashboard read sweeps their own accruals lazily. This\nis the manual trigger for an operator who needs the numbers now. It is idempotent —\nthe per-period latch means running it twice accrues nothing the second time.\n\nA Hanzo platform operation: a caller who is not a SuperAdmin gets 403.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "post_v1_admin_authors_sweep"
},
{
"description": "ConnectAuthor enrols the caller's org in the author program at status \"connected\"\nand returns its enrolment, including the verify code the file method needs. It is\nIDEMPOTENT: a second call returns the same enrolment rather than a conflict.\n\nThe forge login is taken from IAM's LINKED account for the provider when there is\none — that is identity proof, not a claim — and only otherwise from the login in\nthe body, which then has to be proven per repository. Connecting does not admit an\norg to earning: a platform reviewer approves that separately.\n\nAnswers 201 when it enrolled the org and 200 when it found an existing enrolment.",
"inputSchema": {
"properties": {
"githubLogin": {
"description": "GithubLogin is the account to link. Used only when IAM holds no linked\naccount for the provider — a linked account is stronger proof and always wins.",
"type": "string"
},
"login": {
"description": "Login is the provider-neutral alias for GithubLogin, preferred when both are\nsent.",
"type": "string"
},
"provider": {
"description": "Provider is the forge to enrol with: github (the default) or gitlab.",
"type": "string"
}
},
"type": "object"
},
"name": "post_v1_authors_connect"
},
{
"description": "RecordAuthorDeploy records that the caller's org deployed a project built from a\nsource repository, which is the edge that makes an author's work earn royalty.\n\nIt is deliberately NOT an error for a deploy to attribute to nobody: a project\nbuilt from no repository, or from one no author has verified, answers\n{\"recorded\": false, \"reason\"} so a deploy pipeline can fire this on every deploy\nwithout branching. Attribution resolves per-repository first, then owner-wide, so a\nrepository with its own claim always earns for its own author.\n\nA deploy of a Hanzo-maintained template attributes to the platform treasury, and a\nself-deploy (the author's own org deploying its own repository) is recorded for\nprovenance but excluded from accrual. The edge is idempotent per\nrepository+project+org.\n\nAnswers 201 when it recorded a new edge and 200 otherwise.",
"inputSchema": {
"properties": {
"project": {
"description": "Project is the deployed project's id. Required.",
"type": "string"
},
"repoUrl": {
"description": "RepoURL is the source repository the project was built from. Empty means a\nhand-built project with nothing to attribute — an honest no-op, not an error.",
"type": "string"
}
},
"type": "object"
},
"name": "post_v1_authors_deploys_record"
},
{
"description": "VerifyAuthorRepo proves that the caller owns a repository — or a whole OWNER — and\nrecords the claim, which is what makes deploys of that code earn royalty.\n\nOwnership is proven the SAME two ways in both cases, tried in order: an IAM-linked\nforge token with admin or push permission, or a hanzo.json on the default branch\ncarrying the author's verify code. Claiming an OWNER proves it against that\nowner's \".github\" control repository, and is exactly as strong as a per-repository\nclaim — an owner the caller cannot prove is refused with 422, never assumed.\n\nA per-repository claim wins over an owner-wide one, so a specifically-claimed\nrepository always earns for its own author. A repository another author has\nalready verified is a 409. The org must have connected first.\n\nAnswers 201 when it recorded a new claim and 200 when the claim already existed.",
"inputSchema": {
"properties": {
"repoUrl": {
"description": "RepoURL is what to claim: a repository (github.com/owner/name) or a whole\nOWNER (github.com/owner, no repository segment). gitlab.com is accepted too.",
"type": "string"
}
},
"type": "object"
},
"name": "post_v1_authors_repos_verify"
}
]
+1
View File
@@ -0,0 +1 @@
[]
+403
View File
@@ -0,0 +1,403 @@
[
{
"description": "DeleteFlow deletes one automation, its versions and its run history. It answers\nno content, and a flow of another org answers not-found.",
"inputSchema": {
"properties": {
"id": {
"description": "ID is the flow to act on, from the path.",
"type": "string"
}
},
"type": "object"
},
"name": "delete_v1_automations_flows_id"
},
{
"description": "Connectors returns the connector catalogue. Each entry is an external service a\nflow step can invoke, carrying its auth descriptor and the input properties of its\nactions and triggers. The catalogue is the same for every tenant, so the gate is a\nvalidated principal rather than a per-org view.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "get_v1_automations_connectors"
},
{
"description": "ListFlows returns the caller org's automations, most-recently-updated first. The\noptional `limit` query bounds the page.",
"inputSchema": {
"properties": {
"limit": {
"description": "Limit bounds the page (default 200, maximum 1000).",
"type": "integer"
}
},
"type": "object"
},
"name": "get_v1_automations_flows"
},
{
"description": "GetFlow returns one automation and its latest version. That is the flow record\nplus the step tree the builder edits; a flow of another org answers not-found.",
"inputSchema": {
"properties": {
"id": {
"description": "ID is the flow to act on, from the path.",
"type": "string"
}
},
"type": "object"
},
"name": "get_v1_automations_flows_id"
},
{
"description": "ListVersions returns one flow's versions, newest first. The optional `limit`\nquery bounds the page.",
"inputSchema": {
"properties": {
"id": {
"description": "ID is the flow whose versions to list, from the path.",
"type": "string"
},
"limit": {
"description": "Limit bounds the page (default 200, maximum 1000).",
"type": "integer"
}
},
"type": "object"
},
"name": "get_v1_automations_flows_id_versions"
},
{
"description": "Pieces is the retired-name alias of the connector catalogue. It serves exactly\nwhat GET /v1/automations/connectors serves, under the name this surface used\nbefore \"piece\" (the ActivePieces term) became \"connector\", and stays valid for\nclients pinned to the old path. Prefer /connectors.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "get_v1_automations_pieces"
},
{
"description": "ListRuns returns the caller org's run history, newest first. The optional\n`flowId` query narrows it to one flow and `limit` bounds the page.",
"inputSchema": {
"properties": {
"flowId": {
"description": "FlowID narrows the history to one flow. Omit it for the whole org's runs.",
"type": "string"
},
"limit": {
"description": "Limit bounds the page (default 200, maximum 1000).",
"type": "integer"
}
},
"type": "object"
},
"name": "get_v1_automations_runs"
},
{
"description": "GetRun returns one run. A run that has not reached a terminal status is refreshed\nfrom the durable engine first — scoped to the org's own namespace — so the caller\nsees live progress rather than the last status that happened to be persisted.",
"inputSchema": {
"properties": {
"id": {
"description": "ID is the run to read, from the path.",
"type": "string"
}
},
"type": "object"
},
"name": "get_v1_automations_runs_id"
},
{
"description": "UpdateFlow updates one automation's metadata in place. Every field is optional; a\nfield the request omits is left alone. Publishing a version pins which one runs,\nand is refused unless that version belongs to this flow.",
"inputSchema": {
"properties": {
"externalId": {
"description": "ExternalID sets the caller's own id for this flow.",
"type": "string"
},
"folderId": {
"description": "FolderID moves the flow in the builder's tree.",
"type": "string"
},
"id": {
"description": "ID is the flow to update, from the path.",
"type": "string"
},
"metadata": {
"description": "Metadata replaces the caller's opaque JSON."
},
"publishedVersionId": {
"description": "PublishedVersionID pins the version runs execute. It must name a version OF\nTHIS FLOW; empty clears the pin, so runs take the latest version again.",
"type": "string"
}
},
"type": "object"
},
"name": "patch_v1_automations_flows_id"
},
{
"description": "Run executes one connector action in-process and answers the outcome. The\ncaller's resolved credential travels in `auth`, delivered to the action\nverbatim — the runtime resolves no credential itself. An action that ran and\nfailed (or an action name the connector does not have) answers ok:false with\nthe failure message, not an HTTP error; an unknown connector is 404 and a\nmissing action 422.",
"inputSchema": {
"properties": {
"action": {
"description": "Action is the name of the connector action to invoke.",
"type": "string"
},
"auth": {
"description": "Auth is the caller's resolved credential for the connector, handed to the\naction verbatim. Its shape is whatever the connector's auth descriptor\ndeclares (a token string, an object), so it is opaque here.",
"type": "object"
},
"id": {
"description": "ID is the connector to run, from the path.",
"type": "string"
},
"props": {
"additionalProperties": {
"type": "object"
},
"description": "Props are the action's input properties, keyed by property name.",
"type": "object"
}
},
"type": "object"
},
"name": "post_v1_automations_connectors_id_run"
},
{
"description": "CreateFlow creates an automation and its initial DRAFT version in one call. The\nnew flow is DISABLED — creating it does not arm its trigger; POST\n/v1/automations/flows/{id}/enable does that.",
"inputSchema": {
"$defs": {
"FlowAction": {
"properties": {
"displayName": {
"type": "string"
},
"name": {
"type": "string"
},
"nextAction": {
"$ref": "#/$defs/FlowAction"
},
"settings": {
"$ref": "#/$defs/StepSettings"
},
"skip": {
"type": "boolean"
},
"type": {
"description": "PIECE | CODE | ROUTER | LOOP_ON_ITEMS",
"type": "string"
},
"valid": {
"type": "boolean"
}
},
"type": "object"
},
"FlowTrigger": {
"properties": {
"displayName": {
"type": "string"
},
"name": {
"type": "string"
},
"nextAction": {
"$ref": "#/$defs/FlowAction"
},
"settings": {
"$ref": "#/$defs/StepSettings"
},
"strategy": {
"type": "string"
},
"type": {
"description": "PIECE_TRIGGER | EMPTY",
"type": "string"
},
"valid": {
"type": "boolean"
}
},
"type": "object"
},
"StepSettings": {
"properties": {
"actionName": {
"type": "string"
},
"input": {
"additionalProperties": {
"type": "object"
},
"type": "object"
},
"pieceName": {
"type": "string"
},
"pieceVersion": {
"type": "string"
},
"triggerName": {
"type": "string"
}
},
"type": "object"
}
},
"properties": {
"displayName": {
"description": "DisplayName names the flow's initial draft version.",
"type": "string"
},
"externalId": {
"description": "ExternalID is the caller's own id for this flow. Optional.",
"type": "string"
},
"folderId": {
"description": "FolderID groups the flow in the builder's tree. Optional.",
"type": "string"
},
"trigger": {
"$ref": "#/$defs/FlowTrigger",
"description": "Trigger is the root of the step tree — how the flow starts, and the action\nchain that follows. Optional: a flow may be created empty and edited later."
}
},
"type": "object"
},
"name": "post_v1_automations_flows"
},
{
"description": "DisableFlow disarms a flow's trigger and marks it DISABLED. Its schedule and its\nevent subscriptions are dropped, so a disabled flow is never a live target; runs\nalready in flight are unaffected, and it can still be started on demand.",
"inputSchema": {
"properties": {
"id": {
"description": "ID is the flow to act on, from the path.",
"type": "string"
}
},
"type": "object"
},
"name": "post_v1_automations_flows_id_disable"
},
{
"description": "EnableFlow arms a flow's trigger and marks it ENABLED. A POLLING trigger gets a\ncron schedule on the durable engine; a WEBHOOK trigger gets a subscription in the\nrouting index, so an inbound event starts it; a MANUAL trigger arms nothing and\nstill runs on demand.",
"inputSchema": {
"properties": {
"id": {
"description": "ID is the flow to act on, from the path.",
"type": "string"
}
},
"type": "object"
},
"name": "post_v1_automations_flows_id_enable"
},
{
"description": "RunFlow starts one durable run of a flow now. It runs the flow's published\nversion if one is pinned, else its latest, and answers the run record it created.\nThe run is bounded by the org's per-minute run-start budget and its in-flight\nconcurrency ceiling; over either, or with the engine not ready, no run is started\nand no run id is burned.",
"inputSchema": {
"properties": {
"id": {
"description": "ID is the flow to act on, from the path.",
"type": "string"
}
},
"type": "object"
},
"name": "post_v1_automations_flows_id_run"
},
{
"description": "CreateVersion adds a new DRAFT version to a flow. The version is created invalid\nunless it carries a trigger, and it does not become the running version until it\nis published (PATCH the flow's publishedVersionId) or becomes the latest.",
"inputSchema": {
"$defs": {
"FlowAction": {
"properties": {
"displayName": {
"type": "string"
},
"name": {
"type": "string"
},
"nextAction": {
"$ref": "#/$defs/FlowAction"
},
"settings": {
"$ref": "#/$defs/StepSettings"
},
"skip": {
"type": "boolean"
},
"type": {
"description": "PIECE | CODE | ROUTER | LOOP_ON_ITEMS",
"type": "string"
},
"valid": {
"type": "boolean"
}
},
"type": "object"
},
"FlowTrigger": {
"properties": {
"displayName": {
"type": "string"
},
"name": {
"type": "string"
},
"nextAction": {
"$ref": "#/$defs/FlowAction"
},
"settings": {
"$ref": "#/$defs/StepSettings"
},
"strategy": {
"type": "string"
},
"type": {
"description": "PIECE_TRIGGER | EMPTY",
"type": "string"
},
"valid": {
"type": "boolean"
}
},
"type": "object"
},
"StepSettings": {
"properties": {
"actionName": {
"type": "string"
},
"input": {
"additionalProperties": {
"type": "object"
},
"type": "object"
},
"pieceName": {
"type": "string"
},
"pieceVersion": {
"type": "string"
},
"triggerName": {
"type": "string"
}
},
"type": "object"
}
},
"properties": {
"displayName": {
"description": "DisplayName names the new version.",
"type": "string"
},
"id": {
"description": "ID is the flow to add a version to, from the path.",
"type": "string"
},
"trigger": {
"$ref": "#/$defs/FlowTrigger",
"description": "Trigger is the root of the version's step tree. Optional: a version with no\ntrigger is created invalid, and cannot run until one is set."
}
},
"type": "object"
},
"name": "post_v1_automations_flows_id_versions"
}
]
-8
View File
@@ -506,14 +506,6 @@
]
}
},
"/v1/automations/mcp": {
"post": {
"operationId": "post_v1_automations_mcp",
"tags": [
"automations"
]
}
},
"/v1/automations/pieces": {
"get": {
"operationId": "get_v1_automations_pieces",
+10
View File
@@ -0,0 +1,10 @@
[
{
"description": "BaseHealth reports that the base subsystem is serving.\n\nIt is deliberately INDEPENDENT of whether this deployment actually embeds the\nBase engine: the route answers before the CLOUD_BASE_EMBED gate and before the\n/v1/base/* wildcard, so a liveness probe measures the process rather than an\noptional feature, and the wildcard can never shadow it. It reads no tenant, so a\nprober that sends no principal is answered rather than refused.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "get_v1_base_health"
}
]
+1
View File
@@ -0,0 +1 @@
[]
+1
View File
@@ -0,0 +1 @@
[]
+18
View File
@@ -0,0 +1,18 @@
[
{
"description": "list returns every deployable blueprint with its service count and estimated\nmonthly compute cost.\n\nIt is the lightweight index the console renders as a template gallery before\ndrilling into one stack's bill of images — GET /v1/blueprint/sbom?template=\u003cid\u003e\nis the detail view. The cost is the same figure the deploy path meters the\ndeploying org on and the 20% author royalty is taken from, priced from the\nactive rate card (GET /v1/blueprint/health echoes that card).",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "get_v1_blueprint"
},
{
"description": "health reports blueprint liveness and echoes the compute rate card in force.\n\nThe rate card is the one the estimator actually applies after the operator env\noverlay, so an operator can confirm a tuned knob took effect rather than\ninferring it from a price. Not JWT-gated — a liveness probe must be reachable —\nand it always answers 200 while the subsystem is mounted.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "get_v1_blueprint_health"
}
]
+406
View File
@@ -0,0 +1,406 @@
[
{
"description": "ListAccounts returns the org's chart of accounts — the seeded fixed chart every\nposting key in the ledger refers to.",
"inputSchema": {
"properties": {
"sandbox": {
"description": "Sandbox reads the org's SANDBOX ledger when it is exactly \"true\"; anything else\nreads the live one.",
"type": "string"
}
},
"type": "object"
},
"name": "get_v1_books_accounts"
},
{
"description": "BalanceSheet returns the org's Balance Sheet as of `to` (empty = all time), with the\nAssets == Liabilities + Equity equation proof.",
"inputSchema": {
"properties": {
"sandbox": {
"description": "Sandbox reads the org's SANDBOX ledger when it is exactly \"true\".",
"type": "string"
},
"to": {
"description": "To is the RFC3339 instant the statement is struck as of. Empty means all time.",
"type": "string"
}
},
"type": "object"
},
"name": "get_v1_books_balance-sheet"
},
{
"description": "ListBankTransactions returns the org's normalized bank transactions, newest first —\nevery row the import and connector paths have ingested, with its amount in exact cents,\nits direction, and whether it has been matched to a voucher yet.",
"inputSchema": {
"properties": {
"limit": {
"description": "Limit caps how many rows come back; 500 when absent or not positive.",
"type": "integer"
},
"sandbox": {
"description": "Sandbox reads the org's SANDBOX ledger when it is exactly \"true\".",
"type": "string"
}
},
"type": "object"
},
"name": "get_v1_books_bank_transactions"
},
{
"description": "ListUnreconciled returns the org's unmatched bank inflows and their open clarifying\nquestions — the queue a human answers so an unexplained deposit is never guessed into\nrevenue.",
"inputSchema": {
"properties": {
"sandbox": {
"description": "Sandbox reads the org's SANDBOX ledger when it is exactly \"true\"; anything else\nreads the live one.",
"type": "string"
}
},
"type": "object"
},
"name": "get_v1_books_bank_unreconciled"
},
{
"description": "ExportPackage returns the complete financial package for the caller's org over\n(from, to]: the trial balance, the P\u0026L, the balance sheet, and the GL detail behind\nthem — the four statements a tax preparer or an investor asks for, assembled from the\none ledger in a single read so they cannot disagree with each other.",
"inputSchema": {
"properties": {
"format": {
"description": "Format is the export encoding. Only \"json\" is supported; empty means json.",
"type": "string"
},
"from": {
"description": "From is the RFC3339 start of the window, exclusive. Empty means all time.",
"type": "string"
},
"limit": {
"description": "Limit caps the GL detail rows included as the audit trail; 5000 when absent\nor not positive.",
"type": "integer"
},
"sandbox": {
"description": "Sandbox reads the org's SANDBOX ledger when it is exactly \"true\".",
"type": "string"
},
"to": {
"description": "To is the RFC3339 end of the window, inclusive. Empty means up to now.",
"type": "string"
}
},
"type": "object"
},
"name": "get_v1_books_export"
},
{
"description": "ListGL returns the org's most recent GL Entry rows, newest first. This is the raw\ndouble-entry detail behind every statement: one row per leg, with its debit, credit,\nposting time and the source that booked it.",
"inputSchema": {
"properties": {
"limit": {
"description": "Limit caps how many rows come back; 500 when absent or not positive.",
"type": "integer"
},
"sandbox": {
"description": "Sandbox reads the org's SANDBOX ledger when it is exactly \"true\".",
"type": "string"
}
},
"type": "object"
},
"name": "get_v1_books_gl"
},
{
"description": "ListInbox returns the org's open document queue — everything uploaded but not yet\nbooked, newest first, each with its extracted summary and the confidence the scanner\nresolved its category at. A booked document drops out of the queue.",
"inputSchema": {
"properties": {
"sandbox": {
"description": "Sandbox reads the org's SANDBOX ledger when it is exactly \"true\"; anything else\nreads the live one.",
"type": "string"
}
},
"type": "object"
},
"name": "get_v1_books_inbox"
},
{
"description": "Metrics returns the org's deterministic SaaS-metrics snapshot over an optional\n(from, to] window — MRR, ARR, revenue, COGS, burn, gross margin, net income, cash,\ndeferred revenue, monthly burn and runway — as raw int64-cent figures AND the same\nfigures already formatted. Every number is the ledger, aggregated the one way the books\ndefine it, never a guess; it is the grounded read the unified /v1/ask advisor replays.",
"inputSchema": {
"properties": {
"from": {
"description": "From is the RFC3339 start of the window, exclusive. Empty means all time.",
"type": "string"
},
"sandbox": {
"description": "Sandbox reads the org's SANDBOX ledger when it is exactly \"true\".",
"type": "string"
},
"to": {
"description": "To is the RFC3339 end of the window, inclusive. Empty means up to now.",
"type": "string"
}
},
"type": "object"
},
"name": "get_v1_books_metrics"
},
{
"description": "ProfitAndLoss returns the org's accrual-basis Profit \u0026 Loss over an optional (from, to]\nwindow of RFC3339 posting times: recognized revenue, matched cost, and the net.",
"inputSchema": {
"properties": {
"from": {
"description": "From is the RFC3339 start of the window, exclusive. Empty means all time.",
"type": "string"
},
"sandbox": {
"description": "Sandbox reads the org's SANDBOX ledger when it is exactly \"true\".",
"type": "string"
},
"to": {
"description": "To is the RFC3339 end of the window, inclusive. Empty means up to now.",
"type": "string"
}
},
"type": "object"
},
"name": "get_v1_books_pnl"
},
{
"description": "ListQuestions returns the clarifying questions the caller's own recent GL raises — the\nunusual postings a founder should look at (outliers, reversals, round-offs, uncosted\nrevenue, an overdrawn wallet), sharpest first. An empty list means the books look clean;\nthe detector is deterministic over the ledger and invents nothing.",
"inputSchema": {
"properties": {
"sandbox": {
"description": "Sandbox reads the org's SANDBOX ledger when it is exactly \"true\"; anything else\nreads the live one.",
"type": "string"
}
},
"type": "object"
},
"name": "get_v1_books_questions"
},
{
"description": "ListRules returns the org's auto-categorization rules, highest priority first. A rule\nis a standing instruction — \"anything whose merchant contains X books to category Y\" —\nand it overrides a vendor's default category, so this is the list that decides how a\nfuture bill classifies itself.",
"inputSchema": {
"properties": {
"sandbox": {
"description": "Sandbox reads the org's SANDBOX ledger when it is exactly \"true\"; anything else\nreads the live one.",
"type": "string"
}
},
"type": "object"
},
"name": "get_v1_books_rules"
},
{
"description": "ListTransactions returns the org's booked ledger as a single-line register, newest\nfirst: one row per voucher, with its date, description, vendor, category, source and\namount in exact cents. It is the double-entry ledger projected to the register a human\nreads, filterable by posting-time window, category and vendor. Strictly read-only — it\nrestates the books, it never moves them.",
"inputSchema": {
"properties": {
"category": {
"description": "Category filters to one COA account, named by number (\"5300\") or by category\nslug (\"software\").",
"type": "string"
},
"from": {
"description": "From is the RFC3339 start of the posting-time window, inclusive.",
"type": "string"
},
"limit": {
"description": "Limit caps how many rows come back; 200 when absent or not positive.",
"type": "integer"
},
"sandbox": {
"description": "Sandbox reads the org's SANDBOX ledger when it is exactly \"true\".",
"type": "string"
},
"to": {
"description": "To is the RFC3339 end of the posting-time window, inclusive.",
"type": "string"
},
"vendor": {
"description": "Vendor filters to rows whose vendor or description contains this text,\ncase-insensitively.",
"type": "string"
}
},
"type": "object"
},
"name": "get_v1_books_transactions"
},
{
"description": "TrialBalance returns the org's trial balance over an optional [from, to] window of\nRFC3339 posting times, including the opening/closing columns and the\nTotalDebit == TotalCredit proof that the books balance.",
"inputSchema": {
"properties": {
"from": {
"description": "From is the RFC3339 start of the window, exclusive. Empty means all time.",
"type": "string"
},
"sandbox": {
"description": "Sandbox reads the org's SANDBOX ledger when it is exactly \"true\".",
"type": "string"
},
"to": {
"description": "To is the RFC3339 end of the window, inclusive. Empty means up to now.",
"type": "string"
}
},
"type": "object"
},
"name": "get_v1_books_trial-balance"
},
{
"description": "ListVendors returns the org's vendor book: each canonical vendor, the alias spellings a\nreceipt may print it under, and the expense account new bills from it default to. A\nvendor here is what makes a scanned bill self-classify instead of asking again.",
"inputSchema": {
"properties": {
"sandbox": {
"description": "Sandbox reads the org's SANDBOX ledger when it is exactly \"true\"; anything else\nreads the live one.",
"type": "string"
}
},
"type": "object"
},
"name": "get_v1_books_vendors"
},
{
"description": "AskBooks answers a plain-language question about the caller's own books — \"what is my\nMRR?\", \"how long is my runway?\" — with figures taken from their ledger, never a guessed\nnumber. A deterministic keyword router picks the intent and reads the real metrics, and\nthose figures, followups and report sources are computed BEFORE any model call and are\nnever altered by one: the optional narration seam only rephrases the sentence, and it\ndegrades silently to the templated answer when no AI plane is wired. It is strictly\nread-only — it restates the books, it never posts to them.",
"inputSchema": {
"properties": {
"from": {
"description": "From is the RFC3339 start of the metric window. Empty means all time, treated as a\nsingle reporting period (see monthsBetween).",
"type": "string"
},
"question": {
"description": "Question is the plain-language question about the org's books, e.g. \"what is my\nMRR?\". Longer than 2000 characters is truncated, never refused.",
"type": "string"
},
"to": {
"description": "To is the RFC3339 end of the metric window. Empty means up to now.",
"type": "string"
}
},
"type": "object"
},
"name": "post_v1_books_ask"
},
{
"description": "SyncBank pulls every connected bank (Plaid/Teller) for the caller's org, maps each\nfetched transaction to a posting and books it idempotently, then advances that\nconnector's cursor so the next sync resumes where this one stopped. One connector's\noutage is skipped rather than failing the whole sync. It reports the batch: how many\ntransactions were seen, how many vouchers posted, how many inflows reconciled against\nthe processor clearing account, how many raised a question, how many were own-account\ntransfers, and how many were already-processed no-ops. It is READ-ONLY against the\nbank — it ingests, it never sends money.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "post_v1_books_bank_sync"
},
{
"description": "UpsertRule creates or updates one auto-categorization rule, keyed by its pattern —\nwriting a pattern that already exists REPLACES that row's category and priority. The\ncategory is normalized to a real COA expense account, and anything unrecognized becomes\n5900 Uncategorized rather than a guessed real account. It answers the row exactly as\nstored, so the caller sees the normalization. A rule overrides a vendor's default\ncategory, so this is the standing instruction that decides how a future bill classifies.",
"inputSchema": {
"properties": {
"category": {
"description": "Category is the COA expense account a matching bill books to. An upsert normalizes\na slug (\"cloud\") to its account number.",
"type": "string"
},
"pattern": {
"description": "Pattern is the merchant substring the rule matches on, case-insensitively. It is\nalso the key an upsert writes by.",
"type": "string"
},
"priority": {
"description": "Priority breaks ties: when several patterns match, the highest wins.",
"type": "integer"
}
},
"type": "object"
},
"name": "post_v1_books_rules"
},
{
"description": "BookScan posts a reviewed scanned bill to the ledger. It is the scanner's ONLY write:\nthe voucher goes through the same post() choke point every other source uses, so it is\nchecked to balance (Σdebit == Σcredit) and is idempotent by (scan, scanId) — re-booking\nthe same scan answers posted=false and writes nothing. A bill whose economic identity\n(vendor, total, issue date) already posted under a DIFFERENT scan is refused 409 unless\noverride is set, which is what stops the same receipt re-scanned into a new file hash\nfrom double-booking. An unbalanced voucher is refused 400.",
"inputSchema": {
"$defs": {
"Leg": {
"properties": {
"account": {
"description": "Account is the chart-of-accounts number this side posts to, e.g. \"5300\".",
"type": "string"
},
"credit": {
"description": "Credit is the leg's credit in exact cents. Set this or Debit, not both.",
"type": "integer"
},
"debit": {
"description": "Debit is the leg's debit in exact cents. Set this or Credit, not both.",
"type": "integer"
}
},
"type": "object"
},
"Voucher": {
"properties": {
"description": {
"description": "Description is the human line for the event, e.g. the vendor a bill came from.",
"type": "string"
},
"legs": {
"description": "Legs are the sides of the posting. They must balance: Σdebit == Σcredit, give or\ntake the 2¢ round-off allowance.",
"items": {
"$ref": "#/$defs/Leg"
},
"type": "array"
},
"postingAt": {
"description": "PostingAt is the RFC3339 instant the event posts at — the time every statement\nwindow filters on.",
"type": "string"
},
"sourceId": {
"description": "SourceID is the source event's own id within that namespace. Together with\nSourceKind it is the key that makes a repeat posting a no-op.",
"type": "string"
},
"sourceKind": {
"description": "SourceKind is the idempotency namespace naming what booked this, e.g. \"scan\".",
"type": "string"
}
},
"type": "object"
}
},
"properties": {
"override": {
"description": "Override books this bill even when one of the SAME economic identity\n(vendor, total, issue date) already posted — the explicit human confirmation that a\nsame-looking bill is a genuine second spend, not the same receipt re-scanned.",
"type": "boolean"
},
"scanId": {
"description": "ScanID is the scanned document's file hash, as GET /v1/books/inbox and the scan\ndraft report it. It is the idempotency key: re-booking the same scan writes nothing.",
"type": "string"
},
"voucher": {
"$ref": "#/$defs/Voucher",
"description": "Voucher is the reviewed voucher to post. Its source is FORCED to (scan, scanId)\nserver-side, so it can never be booked under another source's key."
}
},
"type": "object"
},
"name": "post_v1_books_scan_book"
},
{
"description": "Sync ingests the caller's OWN org from commerce into BOTH ledgers (live and sandbox)\nand reports how many new vouchers posted to each. It is idempotent — money that has\nalready been booked posts nothing on a repeat — and it is read-only against commerce:\nit never mints a deposit, a credit or a payout, only the accounting twin of money that\nalready moved.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "post_v1_books_sync"
},
{
"description": "UpsertVendor creates or updates one vendor in the org's vendor book, keyed by its\ncanonical name — writing a canonical name that already exists REPLACES that row's\naliases and default category. A category given as a slug (\"software\") is normalized to\nits real COA expense account, and anything unrecognized becomes 5900 Uncategorized\nrather than a guessed real account. It answers the row exactly as stored, so the caller\nsees the normalization. Recording a vendor is what makes future bills from it\nself-classify instead of asking again.",
"inputSchema": {
"properties": {
"aliases": {
"description": "Aliases are the other spellings a receipt may print the vendor under; a scan\nmatching any of them resolves to this vendor.",
"items": {
"type": "string"
},
"type": "array"
},
"canonical": {
"description": "Canonical is the vendor's one true name, and the key an upsert writes by.",
"type": "string"
},
"defaultCategory": {
"description": "DefaultCategory is the COA expense account new bills from this vendor book to.\nAn upsert normalizes a slug (\"software\") to its account number.",
"type": "string"
}
},
"type": "object"
},
"name": "post_v1_books_vendors"
}
]
+10
View File
@@ -0,0 +1,10 @@
[
{
"description": "listNodes returns the caller org's currently connected bot nodes: what each one\ncalls itself, the platform it runs on, its agent version, when its socket was\nestablished, and the capabilities and commands it reported.\n\nOnly this org's nodes are listed — the org is half of every key in the table it\nreads — and only nodes attached to THIS replica, because the list is of live\nsockets rather than of registrations. The capability and command lists are the\nnode's own self-report: useful to show, never load-bearing, because what a node\nmay actually be asked to do is decided at the socket against the deployment's\nallowlist.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "get_v1_bot_nodes"
}
]
+18
View File
@@ -0,0 +1,18 @@
[
{
"description": "List returns the caller org's live bot runs, read from the bot runtime and projected\ninto the console contract with each run's live session URL derived here.\n\nThe org is ALWAYS the validated principal's org, NEVER a request field, and it is\nwhat scopes the runtime's answer — so one tenant can never enumerate another's\nruns. A runtime that cannot answer is an error, not an empty list: [] would tell\nthe caller \"your org has no runs\", which is a different claim from \"we could not\nask\", and the difference is the whole reason this endpoint exists.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "get_v1_bots"
},
{
"description": "Stop terminates one of the caller org's own bot runs and reports its terminal state.\n\nThe own-key guard is the org: it is the caller's validated org, never theirs to\nchoose, and the runtime resolves the run id UNDER it. A run belonging to another\ntenant is not among this org's runs, so it answers absent — the same 404 a\nnonexistent id gets, which is what keeps this from being an oracle.\n\nAbsence is honoured ONLY when the runtime answers it. A runtime that does not\nserve stop reports nothing about the run, and reporting \"stopped\" on that basis\nwould be a stop that cannot fail — so it is a 502.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "post_v1_bots_runId_stop"
}
]
+256
View File
@@ -0,0 +1,256 @@
[
{
"description": "DeleteCampaign removes one campaign of the caller's org and answers 204 with no\nbody. 404 when the org has no campaign with that id.\n\nIt deletes the RECORD, not the executions: a campaign whose channels are live\non a provider should be paused first, or those executions keep running with\nnothing here to report them.",
"inputSchema": {
"properties": {
"id": {
"description": "ID is the campaign's server-minted handle, \"cmp_\"-prefixed.",
"type": "string"
}
},
"type": "object"
},
"name": "delete_v1_campaign_id"
},
{
"description": "RemoveCampaignChannel drops one channel from a campaign and returns the updated\ncampaign. 404 when the campaign carries no channel of that kind.\n\nIt removes the channel from the PLAN. A channel that is live at its provider\nshould be paused first — dropping the row here leaves nothing to pause it with\nafterwards.",
"inputSchema": {
"properties": {
"id": {
"description": "ID is the campaign, from the path.",
"type": "string"
},
"kind": {
"description": "Kind is the channel to remove: paid, organic or email.",
"type": "string"
}
},
"type": "object"
},
"name": "delete_v1_campaign_id_channels_kind"
},
{
"description": "ListCampaigns returns the org's campaigns, newest first, optionally narrowed to\none status.\n\nA campaign is the top-level go-to-market object: a value that SPANS channels\n(paid, organic, email) and fans out to the executor for each. The listing is\norg-scoped server-side, so one org can never see another's campaigns.",
"inputSchema": {
"properties": {
"limit": {
"description": "Limit bounds the page. 0 or less means the default of 200; anything above\n1000 is clamped to 1000.",
"type": "integer"
},
"status": {
"description": "Status keeps only campaigns in that state: draft, live, paused or failed.\nEmpty means any.",
"type": "string"
}
},
"type": "object"
},
"name": "get_v1_campaign"
},
{
"description": "GetCampaign returns one campaign of the caller's org — its name, audience,\ncreatives, channels with their per-channel launch state, schedule, budget and\nstatus. 404 when the org has no campaign with that id.",
"inputSchema": {
"properties": {
"id": {
"description": "ID is the campaign's server-minted handle, \"cmp_\"-prefixed.",
"type": "string"
}
},
"type": "object"
},
"name": "get_v1_campaign_id"
},
{
"description": "CampaignMetrics returns a campaign's results over a window: the analytics\nfunnel (impressions, clicks, conversions, revenue, visitors), the spend each\nchannel's connector reports, and the derived growth KPIs — CTR, CVR, CAC and\nROAS.\n\nThere is exactly ONE metrics plane and nothing is stored here: the funnel is an\nanalytics query over the campaign's utm_campaign-tagged events, and the spend is\neach provider's own number read through the org's connector. A warehouse that is\nnot emitting yet degrades to available:false with zeroes — honest-empty, never a\n500 and never a fabricated number. When the campaign runs more than one creative\nand an experiment is wired, abTest carries the A/B analysis.",
"inputSchema": {
"properties": {
"end": {
"description": "End is an explicit RFC3339 window end.",
"type": "string"
},
"id": {
"description": "ID is the campaign to report on, from the path.",
"type": "string"
},
"range": {
"description": "Range is the lookback window: 24h, 7d, 30d or 90d. Anything else, including\nempty, reads as 30d.",
"type": "string"
},
"start": {
"description": "Start is an explicit RFC3339 window start. Honored only together with End,\nand only when End is after it.",
"type": "string"
}
},
"type": "object"
},
"name": "get_v1_campaign_id_metrics"
},
{
"description": "SummarizeCampaigns returns the org's go-to-market roll-up: how many campaigns\nexist, how many are live, their total budget in cents, and which channel\nexecutors this deployment can actually reach.\n\nThe channel list is the deployment's honest capability, not a wish: a kind\nmissing from it is one a launch will record as \"unavailable\" rather than fail\non.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "get_v1_campaign_summary"
},
{
"description": "CreateCampaign creates a campaign as a DRAFT and returns it.\n\nA draft is inert: nothing is sent, no connector is touched and no budget is\ncommitted until the campaign is launched. The channels named here are validated\nand de-duplicated by kind (one executor per kind), and every channel starts\n\"pending\" whatever the caller claims — a client can never assert a launched\nstate.",
"inputSchema": {
"$defs": {
"ChannelSpec": {
"properties": {
"account": {
"description": "provider account ref (ad-account/page/list id)",
"type": "string"
},
"detail": {
"description": "honest last-outcome detail (never a secret)",
"type": "string"
},
"externalId": {
"type": "string"
},
"kind": {
"description": "paid | organic | email",
"type": "string"
},
"platform": {
"description": "meta | google | x | instagram | (email provider)",
"type": "string"
},
"status": {
"description": "pending | live | paused | failed | unavailable",
"type": "string"
}
},
"type": "object"
}
},
"properties": {
"audience": {
"description": "Audience is the segment or audience selector this campaign targets.",
"type": "string"
},
"budget": {
"description": "Budget is the campaign's total budget in CENTS. Negative reads as 0.",
"type": "integer"
},
"channels": {
"description": "Channels are the fan-out targets, at most one per kind (paid, organic,\nemail) and at most 12. A channel's status and provider id are server-owned:\nwhatever the caller sends for them is replaced with \"pending\".",
"items": {
"$ref": "#/$defs/ChannelSpec"
},
"type": "array"
},
"content": {
"description": "Content is the ordered creative set. Content[0] is the active creative and\nthe rest are A/B variants; at most 32, empty entries dropped.",
"items": {
"type": "string"
},
"type": "array"
},
"name": {
"description": "Name is the campaign's display name. Required; trimmed and capped at 2048\ncharacters.",
"type": "string"
},
"scheduleAt": {
"description": "ScheduleAt is when the campaign should run, in unix seconds. Negative reads\nas 0 (immediately).",
"type": "integer"
}
},
"type": "object"
},
"name": "post_v1_campaign"
},
{
"description": "AddCampaignChannel adds a channel to a campaign, or REPLACES the one it already\nhas of that kind, and returns the updated campaign.\n\nA campaign carries at most one channel per kind, because the kind IS the\nexecutor: adding a second \"paid\" channel would mean two ad accounts running one\ncampaign with no way to tell their results apart. The new channel starts\n\"pending\" — adding it does not launch it.",
"inputSchema": {
"properties": {
"account": {
"description": "Account is the provider account this channel runs under: an ad-account, a\npage, or a mailing-list id.",
"type": "string"
},
"id": {
"description": "ID is the campaign to add the channel to, from the path.",
"type": "string"
},
"kind": {
"description": "Kind is the channel kind and the identity a campaign holds at most one of:\npaid, organic or email.",
"type": "string"
},
"platform": {
"description": "Platform is the provider within the kind — meta, google, x, instagram, or\nthe email provider.",
"type": "string"
}
},
"type": "object"
},
"name": "post_v1_campaign_id_channels"
},
{
"description": "UpdateCampaign rewrites a campaign's core fields — name, audience, creatives,\nschedule and budget — and returns the updated campaign.\n\nChannels are replaced ONLY while the campaign is still a draft. Once it is\nlaunched its channels carry provider state (an external id, a live status), so\nthey are added and removed explicitly through the channels sub-resource\ninstead; a whole-object write would silently orphan a running execution.",
"inputSchema": {
"$defs": {
"ChannelSpec": {
"properties": {
"account": {
"description": "provider account ref (ad-account/page/list id)",
"type": "string"
},
"detail": {
"description": "honest last-outcome detail (never a secret)",
"type": "string"
},
"externalId": {
"type": "string"
},
"kind": {
"description": "paid | organic | email",
"type": "string"
},
"platform": {
"description": "meta | google | x | instagram | (email provider)",
"type": "string"
},
"status": {
"description": "pending | live | paused | failed | unavailable",
"type": "string"
}
},
"type": "object"
}
},
"properties": {
"audience": {
"type": "string"
},
"budget": {
"type": "integer"
},
"channels": {
"items": {
"$ref": "#/$defs/ChannelSpec"
},
"type": "array"
},
"content": {
"items": {
"type": "string"
},
"type": "array"
},
"id": {
"description": "ID is the campaign to update, from the path.",
"type": "string"
},
"name": {
"type": "string"
},
"scheduleAt": {
"type": "integer"
}
},
"type": "object"
},
"name": "put_v1_campaign_id"
}
]
+248
View File
@@ -0,0 +1,248 @@
[
{
"description": "DeleteConvertible removes one of the caller org's convertible notes, taking its\nprincipal out of the cap table's unconverted-instrument totals. An id this org\ndoes not hold is not found.",
"inputSchema": {
"properties": {
"id": {
"description": "ID is the convertible note to delete.",
"type": "string"
}
},
"type": "object"
},
"name": "delete_v1_captable_convertibles_id"
},
{
"description": "DeleteOption removes one of the caller org's option grants, taking its shares\nout of the cap table's granted-options and fully-diluted counts. An id this org\ndoes not hold is not found.",
"inputSchema": {
"properties": {
"id": {
"description": "ID is the option grant to delete.",
"type": "string"
}
},
"type": "object"
},
"name": "delete_v1_captable_options_id"
},
{
"description": "DeleteSafe removes one of the caller org's SAFEs, taking its capital out of the\ncap table's unconverted-instrument totals. An id this org does not hold is not\nfound.",
"inputSchema": {
"properties": {
"id": {
"description": "ID is the SAFE to delete.",
"type": "string"
}
},
"type": "object"
},
"name": "delete_v1_captable_safes_id"
},
{
"description": "DeleteShare removes one of the caller org's share certificates, taking its\nshares out of the cap table's outstanding and fully-diluted counts. An id this\norg does not hold is not found.",
"inputSchema": {
"properties": {
"id": {
"description": "ID is the share certificate to delete.",
"type": "string"
}
},
"type": "object"
},
"name": "delete_v1_captable_shares_id"
},
{
"description": "DeleteStakeholder removes one of the caller org's stakeholders. It REFUSES to\norphan issued equity: a holder that still holds share certificates or option\ngrants cannot be deleted, and answers 400 saying so — release or transfer the\nholdings first. An id this org does not hold is not found.",
"inputSchema": {
"properties": {
"id": {
"description": "ID is the stakeholder to delete.",
"type": "string"
}
},
"type": "object"
},
"name": "delete_v1_captable_stakeholders_id"
},
{
"description": "GetCompany returns the caller org's cap-table company record. The row is\nseeded when the tenant's store first opens, so it always exists; its name and\nincorporation details are set with PUT /v1/captable/company.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "get_v1_captable_company"
},
{
"description": "ListConvertibles returns the caller org's convertible notes, newest first. A\nnote's principal sits OUTSIDE issued equity until it converts, so it is not\npart of the share counts.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "get_v1_captable_convertibles"
},
{
"description": "ListEquityPlans returns the caller org's equity plans, newest first. An equity\nplan is an option pool: a reserve of shares, drawn from one share class, that\noption grants are written against.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "get_v1_captable_equity-plans"
},
{
"description": "ListInvestments returns the caller org's investments, newest first. It spans\nevery round, so it is the flat ledger of cheques written into the company,\neach naming its investor and the round it went into.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "get_v1_captable_investments"
},
{
"description": "ListOptions returns the caller org's option grants, newest first. Each row is\njoined to its grantee and its equity plan. Grants that are EXERCISED, EXPIRED\nor CANCELLED are listed here but do not dilute the cap table.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "get_v1_captable_options"
},
{
"description": "ListRounds returns the caller org's fundraising rounds, newest first. A round\ngroups a fundraising event; a PRICED round also carries the share class and\nprice per share it issues at.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "get_v1_captable_rounds"
},
{
"description": "GetRound returns one of the caller org's fundraising rounds together with every\ninvestment written into it, oldest first. A round id that does not exist in the\ncaller's org is not found — including one that exists in another tenant, since\nthe org comes from the caller's principal and is part of the lookup.",
"inputSchema": {
"properties": {
"id": {
"description": "ID is the round to read. It is the path segment: the URL is the addressing\nauthority, and the org it is resolved in comes from the caller's principal,\nso an id from another tenant is simply not found.",
"type": "string"
}
},
"type": "object"
},
"name": "get_v1_captable_rounds_id"
},
{
"description": "ListSafes returns the caller org's SAFEs, newest first. A SAFE is a simple\nagreement for future equity: its capital sits OUTSIDE issued equity until it\nconverts, so it is not part of the share counts.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "get_v1_captable_safes"
},
{
"description": "ListShareClasses returns the caller org's share classes, in creation order. A\nshare class is what a certificate is issued in, and every class the company\nhas authorized appears. The response is a bare JSON array, not an envelope.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "get_v1_captable_share-classes"
},
{
"description": "ListShares returns the caller org's share certificates, newest first. Each row\nis joined to its holder and its share class, so a certificate names who holds\nit and what class it is in without a second call.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "get_v1_captable_shares"
},
{
"description": "ListStakeholders returns the caller org's stakeholders, newest first. The\nresponse is a bare JSON array, not an envelope. Each row carries the holder's\ncontact and address fields alongside the company's name.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "get_v1_captable_stakeholders"
},
{
"description": "GetSummary computes the caller org's cap table. It answers who owns what on a\nfully-diluted basis: outstanding shares, granted options, per-stakeholder\nownership percentages, each share class's authorized versus issued position,\nand the capital sitting on SAFEs and convertible notes that have not yet\nconverted. Only non-terminal option grants dilute — EXERCISED, EXPIRED and\nCANCELLED grants are excluded, so equity issued through an exercised option is\nnever counted twice.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "get_v1_captable_summary"
},
{
"description": "UpdateStakeholder changes one of the caller org's stakeholders. It is a\nPARTIAL update: only the fields the request names are written, and a field\nsent as null clears that column. A request that names no updatable field is\nrefused, and an id this org does not hold is not found.\n\nThe values are stored as sent. Unlike adding a stakeholder, this route does\nnot check the email's shape or the type and relationship vocabularies, so it\ncan record a value that adding one would have rejected.",
"inputSchema": {
"properties": {
"city": {
"description": "City is the stakeholder's city."
},
"currentRelationship": {
"description": "CurrentRelationship is how the stakeholder relates to the company, e.g.\nFOUNDER, INVESTOR or EMPLOYEE. This route stores it as sent — unlike\nadding a stakeholder, it is not checked against the vocabulary."
},
"email": {
"description": "Email is the stakeholder's email. This route stores it as sent — unlike\nadding a stakeholder, it is not checked for shape or uniqueness."
},
"id": {
"description": "ID is the stakeholder to update. It is the path segment: the URL is the\naddressing authority, and the org it is resolved in comes from the\ncaller's principal, so an id from another tenant is simply not found.",
"type": "string"
},
"institutionName": {
"description": "InstitutionName names the institution, when the stakeholder is one."
},
"name": {
"description": "Name is the stakeholder's full name."
},
"stakeholderType": {
"description": "StakeholderType is INDIVIDUAL or INSTITUTION. This route stores it as\nsent — unlike adding a stakeholder, it is not checked against the\nvocabulary."
},
"state": {
"description": "State is the stakeholder's state or province."
},
"streetAddress": {
"description": "StreetAddress is the stakeholder's street address."
},
"taxId": {
"description": "TaxID is the stakeholder's tax identifier."
},
"zipcode": {
"description": "Zipcode is the stakeholder's postal code."
}
},
"type": "object"
},
"name": "patch_v1_captable_stakeholders_id"
},
{
"description": "CloseRound closes one of the caller org's fundraising rounds, recording the\nclose date and moving its status to CLOSED. Only an OPEN round can be closed:\na round that is already closed — like an id this org does not hold — is not\nfound. Closing a round does not change what was invested in it.",
"inputSchema": {
"properties": {
"closeDate": {
"description": "CloseDate is the date to record the round as closed on. Optional: omitted,\nnull or empty records TODAY. Any JSON scalar is accepted and stored as its\ntext, and the text is stored unparsed, so a caller that wants an ISO date\nsends one."
},
"id": {
"description": "ID is the round to close. It is the path segment: the URL is the\naddressing authority, and the org it is resolved in comes from the\ncaller's principal, so an id from another tenant is simply not found.",
"type": "string"
}
},
"type": "object"
},
"name": "post_v1_captable_rounds_id_close"
},
{
"description": "UpdateCompany sets the caller org's company name and incorporation details.\nThe name is required; the three incorporation fields are optional and each is\nstored as empty when omitted, so a call that sends only a name CLEARS them.\nThe company row itself is seeded when the tenant's store first opens, so this\nnever creates one.",
"inputSchema": {
"properties": {
"incorporationCountry": {
"description": "IncorporationCountry is the ISO country the entity is incorporated in.\nOptional; omitted, null or empty clears it. Any JSON scalar is accepted\nand stored as its text."
},
"incorporationState": {
"description": "IncorporationState is the state or province of incorporation. Optional;\nomitted, null or empty clears it. Any JSON scalar is accepted and stored\nas its text."
},
"incorporationType": {
"description": "IncorporationType is the entity kind, e.g. LLC or C_CORP. Optional;\nomitted, null or empty clears it. Any JSON scalar is accepted and stored\nas its text."
},
"name": {
"description": "Name is the company's legal name. Required, and it must be a non-empty\nstring — anything else is refused with the cap table's own validation\nerror."
}
},
"type": "object"
},
"name": "put_v1_captable_company"
}
]
+51
View File
@@ -0,0 +1,51 @@
[
{
"description": "Browse searches AND browses the cross-org catalog: every project, app and site\nthe fleet has built, whichever org built it.\n\nIt reads TWO corpora and returns them as one page — the published,\nworld-readable catalog that every caller sees, plus the caller's OWN org's\nprivate entries when the request carries a validated principal. Each row says\nwhich it came from in `scope`, so a client can warn before sharing a link. An\nanonymous caller simply gets the published one; no filter can ever widen a\ncaller into another tenant's corpus, because the query that would return it is\nnever run for them.\n\nA request with no q is a browse rather than a search, and both answer the same\nshape: the page, the total before paging, and the facet counts over the whole\nmatching set.",
"inputSchema": {
"properties": {
"archetype": {
"description": "Archetype narrows to one project archetype. Case-insensitive.",
"type": "string"
},
"forkable": {
"description": "Forkable is tri-state: \"true\" selects the forkable rows, \"false\" selects the\nrest, and anything else — including absent — applies no filter at all.",
"type": "string"
},
"kind": {
"description": "Kind narrows to repo | site. Case-insensitive.",
"type": "string"
},
"language": {
"description": "Language narrows to one implementation language. Case-insensitive.",
"type": "string"
},
"limit": {
"description": "Limit caps the page at 200, default 50. A value that is not a non-negative\ninteger falls back to the default.",
"type": "string"
},
"offset": {
"description": "Offset is where the page starts, default 0, with the same tolerance.",
"type": "string"
},
"org": {
"description": "Org narrows to one builder org: hanzo | lux | zoo. Case-insensitive.",
"type": "string"
},
"origin": {
"description": "Origin narrows to what a row IS to you: template | community | third-party |\nproduct. This is the axis the two hanzo.app lanes are cut on.",
"type": "string"
},
"q": {
"description": "Q is the free-text query the lexical index scores relevance on. Empty is a\nbrowse rather than a search — the same request either way.",
"type": "string"
},
"template": {
"description": "Template narrows a lane to ONE lineage: the id of the parent everything\nreturned was forked from.",
"type": "string"
}
},
"type": "object"
},
"name": "get_v1_catalog"
}
]
+1
View File
@@ -0,0 +1 @@
[]
+113
View File
@@ -0,0 +1,113 @@
[
{
"description": "list returns every chat transport channels can talk to — Discord, Slack, Teams\nand Telegram — with the caller org's own facts on each: whether it is\nconnected and to which account, what the transport supports, the org's DM and\ngroup access policies, and how many pairing requests are pending approval. The\norder is fixed, so a console can render the same rows every time. A policy that\ncannot be read leaves that channel's policy fields empty rather than failing\nthe whole listing.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "get_v1_channels"
},
{
"description": "allowlistGet returns the caller org's access policy for one channel: whether\nDMs are pairing-gated, allowlisted or open, whether group rooms are open,\nallowlisted or disabled, the config-managed DM and group allow entries, the\nsenders approved through PAIRING (read-only here), and the org's named access\ngroups. An unknown channel is a 404.",
"inputSchema": {
"properties": {
"channel": {
"description": "Channel is the transport to read: discord, slack, teams or telegram.\nRequired; an unknown value is a 404.",
"type": "string"
}
},
"type": "object"
},
"name": "get_v1_channels_allowlist"
},
{
"description": "inbox returns the messages people have sent to the caller org's connected chat\nbots, oldest first, in the portable envelope shape every transport normalises\ninto. It is a CURSOR feed, not a search: pass the returned cursor back as\n`since` to get only what has arrived since. Only this org's messages are\nstored under this org, so the feed can never carry another tenant's chat.",
"inputSchema": {
"properties": {
"limit": {
"description": "Limit caps how many messages come back. Empty or 0 uses the store's\ndefault page size. Must parse as an integer.",
"type": "string"
},
"since": {
"description": "Since is the exclusive cursor: only messages with a higher row id come\nback. Empty starts at the beginning. Must parse as an integer.",
"type": "string"
}
},
"type": "object"
},
"name": "get_v1_channels_inbox"
},
{
"description": "pairingList returns the pairing requests waiting for the caller org to approve\n— one per person who messaged a connected bot on a channel whose DM policy is\n\"pairing\" and who is not allowed yet. Each row carries the CODE an org admin\npasses to POST /v1/channels/pairing/approve. Expired requests are not\nreturned. Codes are capability strings: they are shown here, and never logged.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "get_v1_channels_pairing"
},
{
"description": "pairingApprove turns one pending pairing code into a standing allow entry, so\nthat person can DM the org's bot on that channel from now on. It requires ORG\nADMIN, not merely membership. The first approval an org makes on a channel also\nbootstraps that sender as the channel's owner, which the answer reports. An\nunknown or expired code is a 404, and a code always belongs to exactly one\norg, so it can never approve someone into another tenant.",
"inputSchema": {
"properties": {
"channel": {
"description": "Channel is the transport the request came in on: discord, slack, teams or telegram.",
"type": "string"
},
"code": {
"description": "Code is the pairing code from GET /v1/channels/pairing. It is a capability:\nholding it is what authorises the approval, alongside org admin.",
"type": "string"
}
},
"type": "object"
},
"name": "post_v1_channels_pairing_approve"
},
{
"description": "allowlistPut edits the caller org's access policy for one channel and answers\nthe policy as GET would, so both verbs return ONE shape. It requires ORG ADMIN.\nEvery field but `channel` is optional and applied only when provided: an empty\npolicy string leaves that policy alone, an absent or null list leaves that list\nalone, and an EMPTY list clears it. It writes only CONFIG-sourced allow entries\n— senders approved through pairing belong to the approval flow, so a policy\nedit can never revoke one. An unknown channel is a 404.",
"inputSchema": {
"properties": {
"accessGroups": {
"additionalProperties": {
"additionalProperties": {
"items": {
"type": "string"
},
"type": "array"
},
"type": "object"
},
"description": "AccessGroups REPLACES the org's named access groups, as\ngroup name -\u003e channel -\u003e entries. Absent or null leaves them alone.",
"type": "object"
},
"channel": {
"description": "Channel is the transport to edit: discord, slack, teams or telegram.\nRequired; an unknown value is a 404.",
"type": "string"
},
"dm": {
"description": "DM REPLACES the config-managed DM allow entries. Absent or null leaves them\nalone; an empty list clears them. It never touches senders approved through\npairing — a policy edit cannot revoke an approved pairing.",
"items": {
"type": "string"
},
"type": "array"
},
"dmPolicy": {
"description": "DMPolicy sets how direct messages are admitted: \"pairing\" (a person must be\napproved first), \"allowlist\" (only listed senders) or \"open\". Empty leaves\nit unchanged.",
"type": "string"
},
"group": {
"description": "Group REPLACES the config-managed group allow entries. Absent or null\nleaves them alone; an empty list clears them.",
"items": {
"type": "string"
},
"type": "array"
},
"groupPolicy": {
"description": "GroupPolicy sets how group and thread rooms are admitted: \"open\",\n\"allowlist\" or \"disabled\". Empty leaves it unchanged.",
"type": "string"
}
},
"type": "object"
},
"name": "put_v1_channels_allowlist"
}
]
+549
View File
@@ -0,0 +1,549 @@
[
{
"description": "D1DatabaseDelete deletes a D1 database and everything stored in it. Requires\norg admin.",
"inputSchema": {
"properties": {
"database": {
"description": "Database is the Cloudflare D1 database id or name.",
"type": "string"
}
},
"type": "object"
},
"name": "delete_v1_cloudflare_d1_databases_database"
},
{
"description": "KVNamespaceDelete deletes a Workers KV namespace and every key in it. Requires\norg admin.",
"inputSchema": {
"properties": {
"namespace": {
"description": "Namespace is the Cloudflare KV namespace id.",
"type": "string"
}
},
"type": "object"
},
"name": "delete_v1_cloudflare_kv_namespaces_namespace"
},
{
"description": "KVValueDelete removes one key from a Workers KV namespace. Requires org admin.",
"inputSchema": {
"properties": {
"key": {
"description": "Key is the key within that namespace. KV keys are broad (up to 512 bytes),\nso this one is escaped rather than charset-restricted.",
"type": "string"
},
"namespace": {
"description": "Namespace is the Cloudflare KV namespace id.",
"type": "string"
}
},
"type": "object"
},
"name": "delete_v1_cloudflare_kv_namespaces_namespace_values_key"
},
{
"description": "PagesDelete deletes a Cloudflare Pages project, and with it every deployment it\nhas ever made. Requires org admin.",
"inputSchema": {
"properties": {
"project": {
"description": "Project is the Pages project name.",
"type": "string"
}
},
"type": "object"
},
"name": "delete_v1_cloudflare_pages_projects_project"
},
{
"description": "PagesDomainDelete detaches a custom domain from a Cloudflare Pages project.\nRequires org admin.",
"inputSchema": {
"properties": {
"domain": {
"description": "Domain is the attached custom domain to detach.",
"type": "string"
},
"project": {
"description": "Project is the Pages project name.",
"type": "string"
}
},
"type": "object"
},
"name": "delete_v1_cloudflare_pages_projects_project_domains_domain"
},
{
"description": "R2BucketDelete deletes an R2 bucket. Requires org admin. Cloudflare refuses a\nbucket that still holds objects, and that refusal is relayed.",
"inputSchema": {
"properties": {
"bucket": {
"description": "Bucket is the R2 bucket name.",
"type": "string"
}
},
"type": "object"
},
"name": "delete_v1_cloudflare_r2_buckets_bucket"
},
{
"description": "WorkersScriptDelete removes a Worker script from the org's Cloudflare account.\nRequires org admin. Routes bound to the script stop serving it.",
"inputSchema": {
"properties": {
"script": {
"description": "Script is the Worker script name.",
"type": "string"
}
},
"type": "object"
},
"name": "delete_v1_cloudflare_workers_scripts_script"
},
{
"description": "WorkersRouteDelete unbinds a Worker route, so its pattern stops dispatching to a\nscript. Requires org admin.",
"inputSchema": {
"properties": {
"route": {
"description": "Route is the 32-hex Cloudflare route id.",
"type": "string"
},
"zone": {
"description": "Zone is the 32-hex Cloudflare zone id.",
"type": "string"
}
},
"type": "object"
},
"name": "delete_v1_cloudflare_workers_zones_zone_routes_route"
},
{
"description": "D1DatabaseList lists the D1 databases on the org's Cloudflare account. Any org\nmember may read.",
"inputSchema": {
"properties": {
"name": {
"description": "Name filters to the database with this name.",
"type": "string"
},
"page": {
"description": "Page is the 1-based page of databases to return.",
"type": "string"
},
"per_page": {
"description": "PerPage is how many databases one page holds.",
"type": "string"
}
},
"type": "object"
},
"name": "get_v1_cloudflare_d1_databases"
},
{
"description": "KVNamespaceList lists the Workers KV namespaces on the org's Cloudflare\naccount. Any org member may read.",
"inputSchema": {
"properties": {
"direction": {
"type": "string"
},
"order": {
"description": "Order names the field to sort by, and Direction sorts asc or desc.",
"type": "string"
},
"page": {
"description": "Page is the 1-based page of namespaces to return.",
"type": "string"
},
"per_page": {
"description": "PerPage is how many namespaces one page holds.",
"type": "string"
}
},
"type": "object"
},
"name": "get_v1_cloudflare_kv_namespaces"
},
{
"description": "PagesList lists the org's Cloudflare Pages projects. Any org member may read.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "get_v1_cloudflare_pages_projects"
},
{
"description": "PagesGet reads one Cloudflare Pages project — its build config, deployment\nconfigs and latest deployment. Any org member may read.",
"inputSchema": {
"properties": {
"project": {
"description": "Project is the Pages project name.",
"type": "string"
}
},
"type": "object"
},
"name": "get_v1_cloudflare_pages_projects_project"
},
{
"description": "R2BucketList lists the R2 buckets on the org's Cloudflare account. Any org\nmember may read.",
"inputSchema": {
"properties": {
"cursor": {
"description": "Cursor continues from the position a previous page returned.",
"type": "string"
},
"direction": {
"type": "string"
},
"name_contains": {
"description": "NameContains filters to buckets whose name contains this substring.",
"type": "string"
},
"order": {
"description": "Order names the field to sort by, and Direction sorts asc or desc.",
"type": "string"
},
"per_page": {
"description": "PerPage is how many buckets one page holds.",
"type": "string"
}
},
"type": "object"
},
"name": "get_v1_cloudflare_r2_buckets"
},
{
"description": "WorkersScriptList lists the Worker scripts on the org's Cloudflare account. Any\norg member may read.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "get_v1_cloudflare_workers_scripts"
},
{
"description": "WorkersSubdomainGet reads the org account's workers.dev subdomain — the name\nunder which every subdomain-enabled script is served. Any org member may read.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "get_v1_cloudflare_workers_subdomain"
},
{
"description": "WorkersRouteList lists the Worker routes bound within one zone — the URL\npatterns that dispatch to a script. Any org member may read. Routes are\nzone-scoped, so no account is resolved.",
"inputSchema": {
"properties": {
"zone": {
"description": "Zone is the 32-hex Cloudflare zone id.",
"type": "string"
}
},
"type": "object"
},
"name": "get_v1_cloudflare_workers_zones_zone_routes"
},
{
"description": "ZonesList lists the Cloudflare zones the org's connected API token can see,\npaged and filtered by the query parameters Cloudflare itself accepts. Zones are\ntoken-scoped by Cloudflare, so no account is resolved. Any org member may read.\n\nZone and DNS-record MANAGEMENT is not here: it stays on the Hanzo DNS plane\n(/v1/dns). This only surfaces the Cloudflare zone objects the asset plane needs\n— a zone id is what addresses a Worker route or an analytics read.",
"inputSchema": {
"properties": {
"direction": {
"type": "string"
},
"name": {
"description": "Name filters to the zone with this domain name.",
"type": "string"
},
"order": {
"description": "Order names the field to sort by, and Direction sorts asc or desc.",
"type": "string"
},
"page": {
"description": "Page is the 1-based page of zones to return.",
"type": "string"
},
"per_page": {
"description": "PerPage is how many zones one page holds.",
"type": "string"
},
"status": {
"description": "Status filters by zone status (active, pending, initializing, …).",
"type": "string"
}
},
"type": "object"
},
"name": "get_v1_cloudflare_zones"
},
{
"description": "ZoneGet reads one Cloudflare zone the org's token can see. Any org member may\nread. A zone id the token cannot see is Cloudflare's own not-found, relayed.",
"inputSchema": {
"properties": {
"zone": {
"description": "Zone is the 32-hex Cloudflare zone id.",
"type": "string"
}
},
"type": "object"
},
"name": "get_v1_cloudflare_zones_zone"
},
{
"description": "ZoneAnalytics reads a zone's Cloudflare traffic dashboard — requests, bandwidth,\nthreats and pageviews over the since/until window. Any org member may read.\n\nA zone whose Cloudflare plan does not serve this endpoint yields Cloudflare's\nOWN error, never a fabricated success.",
"inputSchema": {
"properties": {
"continuous": {
"description": "Continuous asks Cloudflare for only fully-aggregated buckets.",
"type": "string"
},
"since": {
"description": "Since and Until bound the window, in the form Cloudflare accepts — an RFC 3339\ntime or a negative number of minutes from now (\"-1440\" is the last day).",
"type": "string"
},
"until": {
"type": "string"
},
"zone": {
"description": "Zone is the 32-hex Cloudflare zone id.",
"type": "string"
}
},
"type": "object"
},
"name": "get_v1_cloudflare_zones_zone_analytics"
},
{
"description": "D1DatabaseCreate creates a D1 database on the org's Cloudflare account.\nRequires org admin.",
"inputSchema": {
"properties": {
"name": {
"description": "Name is the database name to create.",
"type": "string"
}
},
"type": "object"
},
"name": "post_v1_cloudflare_d1_databases"
},
{
"description": "KVNamespaceCreate creates a Workers KV namespace on the org's Cloudflare\naccount. Requires org admin. Cloudflare mints the namespace id the value routes\naddress.",
"inputSchema": {
"properties": {
"title": {
"description": "Title is the namespace's display title. Cloudflare mints the id.",
"type": "string"
}
},
"type": "object"
},
"name": "post_v1_cloudflare_kv_namespaces"
},
{
"description": "PagesCreate creates a Cloudflare Pages project on the org's account. Requires\norg admin. Only the modeled fields reach Cloudflare, so an unmodeled key in the\nrequest is dropped rather than forwarded.",
"inputSchema": {
"$defs": {
"PagesBuildConfig": {
"properties": {
"build_command": {
"type": "string"
},
"destination_dir": {
"type": "string"
},
"root_dir": {
"type": "string"
}
},
"type": "object"
},
"PagesD1Binding": {
"properties": {
"id": {
"type": "string"
}
},
"type": "object"
},
"PagesDeploymentConfig": {
"properties": {
"compatibility_date": {
"type": "string"
},
"compatibility_flags": {
"items": {
"type": "string"
},
"type": "array"
},
"d1_databases": {
"additionalProperties": {
"$ref": "#/$defs/PagesD1Binding"
},
"type": "object"
},
"env_vars": {
"additionalProperties": {
"$ref": "#/$defs/PagesEnvVar"
},
"type": "object"
},
"kv_namespaces": {
"additionalProperties": {
"$ref": "#/$defs/PagesKVBinding"
},
"type": "object"
},
"r2_buckets": {
"additionalProperties": {
"$ref": "#/$defs/PagesR2Binding"
},
"type": "object"
}
},
"type": "object"
},
"PagesDeploymentConfigs": {
"properties": {
"preview": {
"$ref": "#/$defs/PagesDeploymentConfig"
},
"production": {
"$ref": "#/$defs/PagesDeploymentConfig"
}
},
"type": "object"
},
"PagesEnvVar": {
"properties": {
"type": {
"type": "string"
},
"value": {
"type": "string"
}
},
"type": "object"
},
"PagesKVBinding": {
"properties": {
"namespace_id": {
"type": "string"
}
},
"type": "object"
},
"PagesR2Binding": {
"properties": {
"name": {
"type": "string"
}
},
"type": "object"
}
},
"properties": {
"build_config": {
"$ref": "#/$defs/PagesBuildConfig"
},
"deployment_configs": {
"$ref": "#/$defs/PagesDeploymentConfigs"
},
"name": {
"type": "string"
},
"production_branch": {
"type": "string"
}
},
"type": "object"
},
"name": "post_v1_cloudflare_pages_projects"
},
{
"description": "PagesDomainAdd attaches a custom domain to a Cloudflare Pages project. Requires\norg admin. Cloudflare owns validation and certificate issuance from here on.",
"inputSchema": {
"properties": {
"name": {
"description": "Name is the custom domain to attach, e.g. \"www.acme.com\".",
"type": "string"
},
"project": {
"description": "Project is the Pages project name, from the path.",
"type": "string"
}
},
"type": "object"
},
"name": "post_v1_cloudflare_pages_projects_project_domains"
},
{
"description": "R2BucketCreate creates an R2 bucket on the org's Cloudflare account. Requires\norg admin.",
"inputSchema": {
"properties": {
"name": {
"description": "Name is the bucket name to create.",
"type": "string"
}
},
"type": "object"
},
"name": "post_v1_cloudflare_r2_buckets"
},
{
"description": "WorkersScriptSubdomainSet publishes or withdraws one Worker script on the\naccount's workers.dev subdomain. Requires org admin.",
"inputSchema": {
"properties": {
"enabled": {
"description": "Enabled publishes the script on \u003cscript\u003e.\u003csubdomain\u003e.workers.dev when true,\nand withdraws it when false.",
"type": "boolean"
},
"script": {
"description": "Script is the Worker script name, from the path.",
"type": "string"
}
},
"type": "object"
},
"name": "post_v1_cloudflare_workers_scripts_script_subdomain"
},
{
"description": "WorkersRouteCreate binds a URL pattern in a zone to a Worker script. Requires\norg admin — a route is what puts a script in front of live traffic.",
"inputSchema": {
"properties": {
"pattern": {
"description": "Pattern is the URL pattern to bind, e.g. \"acme.com/api/*\".",
"type": "string"
},
"script": {
"description": "Script is the Worker script to dispatch to. Omit it to leave the pattern\nbound to no script, which is how Cloudflare expresses \"bypass the Worker here\".",
"type": "string"
},
"zone": {
"description": "Zone is the 32-hex Cloudflare zone id, from the path.",
"type": "string"
}
},
"type": "object"
},
"name": "post_v1_cloudflare_workers_zones_zone_routes"
},
{
"description": "ZonePurge drops a zone's Cloudflare edge cache — either the whole zone\n(purge_everything) or exactly the listed file URLs. Requires org admin.\n\nPurging is the one zone-scoped WRITE this plane owns. It is not DNS — no record\nchanges — so it does not belong on /v1/dns, and it is not a connection, so it does\nnot belong on the integrations plane. It is a cache operation on a zone, which is\nwhat this asset plane is for. It takes the admin gate because dropping a zone's\ncache sends every subsequent request to the origin: on a site fronting a small\norigin that is a self-inflicted load spike, so it is a change, not a look.\n\nExactly one selector is required. Cloudflare treats a body with neither as a\nno-op and answers 200, which reads as \"purged\" to a caller that never purged\nanything — the failure we refuse to pass through.",
"inputSchema": {
"properties": {
"files": {
"description": "Files purges exactly the listed URLs — at most 30, Cloudflare's per-request cap.",
"items": {
"type": "string"
},
"type": "array"
},
"purge_everything": {
"description": "Everything drops the zone's entire edge cache.",
"type": "boolean"
},
"zone": {
"description": "Zone is the 32-hex Cloudflare zone id, from the path.",
"type": "string"
}
},
"type": "object"
},
"name": "post_v1_cloudflare_zones_zone_purge"
}
]
+151
View File
@@ -0,0 +1,151 @@
[
{
"description": "askGet answers a question about the caller org's code with a CITED answer:\nretrieval packs grounding context, then the synthesizer writes the answer over\nexactly those spans, which come back alongside it. It never answers without\ngrounding — with no matched code the answer is empty and says so, and with no\nsynthesizer available the citations still come back with \"degraded\": true so\nthe caller can reason over the spans itself.",
"inputSchema": {
"properties": {
"q": {
"description": "Q is the question to answer. Required, max 4000 bytes.",
"type": "string"
},
"repo": {
"description": "Repo narrows retrieval to one repository. Empty searches every repo the org\nhas indexed.",
"type": "string"
}
},
"type": "object"
},
"name": "get_v1_code_ask"
},
{
"description": "file returns the INDEXED content of one file — read_file over the chunks the\nsearch tiers hold, for pulling up code an agent just found. It is NOT\nbyte-verbatim: the git object plane is the source of record for exact bytes,\nhistory and blame. A file absent from the index is a 404, so an agent can tell\n\"not indexed\" from \"empty file\".",
"inputSchema": {
"properties": {
"path": {
"description": "Path is the file's repo-relative path. Required.",
"type": "string"
},
"repo": {
"description": "Repo is the repository the file belongs to. REQUIRED.",
"type": "string"
}
},
"type": "object"
},
"name": "get_v1_code_file"
},
{
"description": "search finds code in the caller org's index across three orthogonal retrieval\ntiers fused by reciprocal-rank fusion: lexical (FTS5 trigram over\ncode-tokenized text), symbolic (real definition and reference edges), and\nsemantic (embedding cosine over AST-boundary chunks). Pick one tier with\n`type`, or leave it to run all three as hybrid, which is what a coding agent\nusually wants. It is FAIL-HONEST: a retrieval outage answers 200 with an empty\nresult set and \"degraded\": true rather than a 5xx, so an agent degrades instead\nof stalling. A malformed regex is a 400.",
"inputSchema": {
"properties": {
"limit": {
"description": "Limit caps how many spans come back: default 20, maximum 100. A value that\nis not a positive integer reads as the default.",
"type": "integer"
},
"q": {
"description": "Q is the search query. Required, max 4000 bytes. For type=regex it is a\nregular expression; for type=symbol it is a symbol name.",
"type": "string"
},
"repo": {
"description": "Repo narrows to one repository. Empty searches every repo the org has indexed.",
"type": "string"
},
"type": {
"description": "Type selects the retrieval tier: \"text\" (FTS5 trigram), \"regex\",\n\"symbol\" (definitions), \"semantic\" (embeddings) or \"hybrid\". Anything\nelse — including empty — reads as hybrid.",
"type": "string"
}
},
"type": "object"
},
"name": "get_v1_code_search"
},
{
"description": "tree returns one repository's file structure with a per-file symbol count —\nget_repo_structure over the org's own index, with no git checkout involved. A\nrepository that has not been indexed answers an empty tree rather than an\nerror, so an agent can tell \"nothing here\" without handling a failure.",
"inputSchema": {
"properties": {
"repo": {
"description": "Repo is the repository to walk. REQUIRED — a tree is repo-scoped.",
"type": "string"
}
},
"type": "object"
},
"name": "get_v1_code_tree"
},
{
"description": "askPost is askGet with the question in the request BODY, for a question too\nlong or too awkward to put in a URL. `query` and `repo` in the body take\nprecedence over `?q=` and `?repo=`; either source works alone.",
"inputSchema": {
"properties": {
"query": {
"description": "Query is the question, from the BODY. Takes precedence over `?q=`.",
"type": "string"
},
"repo": {
"description": "Repo is the repository narrowing, from the BODY. Takes precedence over `?repo=`.",
"type": "string"
}
},
"type": "object"
},
"name": "post_v1_code_ask"
},
{
"description": "context packs the most relevant code for a query into a token budget — THE\nprimitive for a coding agent that has to decide what to put in a prompt. It\nretrieves seed spans, expands each with the definitions it calls and its key\ncallers, then greedily fills the budget, so the answer is a coherent slice of\nthe codebase rather than a list of disconnected matches. The top match is\nalways included, truncated if it alone overflows, so a matched query never\ncomes back empty. A retrieval outage answers 200 with an empty bundle rather\nthan a 5xx.",
"inputSchema": {
"properties": {
"budgetTokens": {
"description": "BudgetTokens caps the bundle's size. Clamped to [256, 32000]; 0 or absent\nuses 4000.",
"type": "integer"
},
"query": {
"description": "Query is what to retrieve context for. Required, max 4000 bytes.",
"type": "string"
},
"repo": {
"description": "Repo narrows retrieval to one repository. Empty searches every repo the org\nhas indexed.",
"type": "string"
}
},
"type": "object"
},
"name": "post_v1_code_context"
},
{
"description": "index (re)indexes a repository for the caller's org, incrementally: files whose\ncontent hash is unchanged are skipped, so re-sending a whole tree is cheap.\nEach file is parsed for symbols, split at AST boundaries and — when the\nsemantic tier is available — embedded, which is what makes it searchable across\nall three retrieval tiers. Pass `prune` to also DELETE indexed files absent\nfrom the request, which turns the call into a full sync; without it the call is\nan upsert. The index is written to the caller org's own physically separate\ndatabase.",
"inputSchema": {
"$defs": {
"fileInput": {
"properties": {
"content": {
"description": "Content is the file's full text. Max 1 MiB per file; binary files should\nsimply be omitted rather than sent.",
"type": "string"
},
"path": {
"description": "Path is the file's repo-relative path, e.g. \"internal/store/db.go\".",
"type": "string"
}
},
"type": "object"
}
},
"properties": {
"files": {
"description": "Files is the full set of files to index. Required and non-empty; max 20000\nfiles, 1 MiB per file and 1 GiB in total. Unchanged files are skipped by\ncontent hash, so re-sending the whole tree is cheap.",
"items": {
"$ref": "#/$defs/fileInput"
},
"type": "array"
},
"prune": {
"description": "Prune deletes indexed files that are NOT in this request — which makes the\ncall a full sync of the repo rather than an upsert. Only pass it when Files\nis the complete tree.",
"type": "boolean"
},
"repo": {
"description": "Repo is the repository label to index under. Required, max 200 bytes. It is\na stored column value, not a filesystem path.",
"type": "string"
}
},
"type": "object"
},
"name": "post_v1_code_index"
}
]
+1
View File
@@ -0,0 +1 @@
[]
+341
View File
@@ -0,0 +1,341 @@
[
{
"description": "Get returns the caller org's formation and the stages reachable from it, or 404\nwhen the org has not begun one.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "get_v1_company"
},
{
"description": "ListRegister returns the platform's whole formation register, newest activity\nfirst — every org's formation, not the caller's. It is a Hanzo platform\noperation: a caller who is not a platform reviewer gets 403.\n\nFilter by stage and structure, page with limit and offset. An unknown stage is\nrefused with 400 rather than returning a silently empty page.",
"inputSchema": {
"properties": {
"limit": {
"description": "Limit bounds the page; 0 or less means the default of 200.",
"type": "integer"
},
"offset": {
"description": "Offset skips that many rows.",
"type": "integer"
},
"stage": {
"description": "Stage keeps only formations at that stage. Empty means any.",
"type": "string"
},
"structure": {
"description": "Structure keeps only formations of that entity kind. Empty means any.",
"type": "string"
}
},
"type": "object"
},
"name": "get_v1_company_register"
},
{
"description": "SummarizeRegister counts the platform's formations by stage — the register's\nshape in one read, so a queue that is growing is visible as a number rather\nthan inferred by paging the list. A Hanzo platform operation: a caller who is\nnot a platform reviewer gets 403.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "get_v1_company_register_summary"
},
{
"description": "ReviewQueue reports the founders whose KYC is not yet settled, oldest formation\nfirst, so the queue drains in the order founders have been waiting. A Hanzo\nplatform operation: a caller who is not a platform reviewer gets 403.\n\nIt only says who is waiting; the decision itself is POST\n/v1/company/kyc/decision.",
"inputSchema": {
"properties": {
"limit": {
"description": "Limit bounds how many formations are scanned; 0 or less means the default of 200.",
"type": "integer"
}
},
"type": "object"
},
"name": "get_v1_company_review"
},
{
"description": "Begin starts the org's one formation and returns it with the stages reachable\nfrom it. It is idempotent: an org that already has a formation gets that one\nback with 200, while a first call creates it and answers 201.",
"inputSchema": {
"properties": {
"alreadyIncorporated": {
"description": "AlreadyIncorporated declares an org that already has an entity, which takes\nthe import path (POST /v1/company/skip) instead of the formation path.",
"type": "boolean"
},
"jurisdiction": {
"description": "Jurisdiction is the state of formation: DE or WY.",
"type": "string"
},
"name": {
"description": "Name is the proposed company name.",
"type": "string"
},
"structure": {
"description": "Structure is the legal entity to form: c-corp, llc or dao-llc.",
"type": "string"
}
},
"type": "object"
},
"name": "post_v1_company"
},
{
"description": "Advance runs the ONE guarded transition of the formation machine. It is the\nonly door between stages: the actions populate data, this decides ordering.\n\nAn edge the machine does not define answers 409; an edge whose guard is not yet\nsatisfied answers 422 naming what is missing. Reaching the terminal `company`\nstage also records the incorporation on the canonical cap table, and that must\nsucceed before the transition is persisted.",
"inputSchema": {
"properties": {
"to": {
"description": "To is the target stage: structure, founders, payment, documents, esign,\ngenesis, import or company.",
"type": "string"
}
},
"type": "object"
},
"name": "post_v1_company_advance"
},
{
"description": "GenerateDocuments renders the formation documents for the chosen structure and\njurisdiction, ingests each into the org's data room, and submits the state\nfiling through the filing seam.\n\nWith no filing partner wired the filing is recorded honestly as \"manual\" — no\nfiling id is fabricated. Available only at the documents stage.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "post_v1_company_documents"
},
{
"description": "RequestEsign sends the generated formation documents for signature by every\nfounder and records the provider's reference on the formation. Available only\nat the esign stage.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "post_v1_company_esign"
},
{
"description": "CompleteEsign records whether the formation documents have been signed. It\nconsults the e-signature provider, which a real provider's webhook drives; the\nsignal is idempotent.\n\nAn explicit `signed` in the request overrides the provider's answer, which is\nthe manual path for the stub provider that never self-completes.",
"inputSchema": {
"properties": {
"signed": {
"description": "Signed, when present, overrides what the provider reports — the manual path\nfor a provider whose webhook is not wired. Omit it to take the provider's answer.",
"type": "boolean"
}
},
"type": "object"
},
"name": "post_v1_company_esign_complete"
},
{
"description": "SetFounders replaces the formation's founders. Each founder needs a name, an\nemail and an equity share in basis points; every founder is (re)set to pending\nKYC, so a previously settled decision does not survive a change of the list.",
"inputSchema": {
"$defs": {
"Founder": {
"properties": {
"decidedBy": {
"description": "DecidedBy is who settled a terminal KYC status: the provider name, or a\nreviewer's user id.",
"type": "string"
},
"email": {
"description": "Email is the founder's email, and the key a KYC decision addresses a founder\nby — POST /v1/company/kyc/decision matches on it.",
"type": "string"
},
"equityBps": {
"description": "EquityBps is the founder's ownership in basis points, 010000 (1% == 100 bps,\nso 10000 is the whole company). The founders' shares seed the cap-table genesis.",
"type": "integer"
},
"kycRef": {
"description": "KYCRef is the idv provider's session reference for this founder.",
"type": "string"
},
"kycStatus": {
"description": "KYCStatus is the founder's identity-verification state: pending, verified (a\nreal idv provider reported a pass), reviewer_confirmed (a privileged reviewer\nconfirmed out-of-band) or failed. The payment stage is unreachable until every\nfounder passes.",
"type": "string"
},
"name": {
"description": "Name is the founder's full legal name, as it appears on the formation documents.",
"type": "string"
}
},
"type": "object"
}
},
"properties": {
"founders": {
"description": "Founders is every founding stakeholder. Each needs a name and an email, and\nequityBps between 0 and 10000 (1% == 100 bps).",
"items": {
"$ref": "#/$defs/Founder"
},
"type": "array"
}
},
"type": "object"
},
"name": "post_v1_company_founders"
},
{
"description": "RecordRound records a fundraising round on the org's canonical cap table.\nAvailable only after incorporation (stage company); roundType defaults to\nPRICED.",
"inputSchema": {
"properties": {
"name": {
"description": "Name is the round's name on the cap table, e.g. \"Seed\". Required.",
"type": "string"
},
"preMoneyValuation": {
"description": "PreMoneyValuation is the valuation the round prices off, before the new money.",
"type": "number"
},
"pricePerShare": {
"description": "PricePerShare is the per-share price of a priced round.",
"type": "number"
},
"roundType": {
"description": "RoundType is PRICED, SAFE or CONVERTIBLE_NOTE. Defaults to PRICED.",
"type": "string"
},
"shareClassId": {
"description": "ShareClassID is the cap table's share class the round issues into.",
"type": "string"
},
"targetAmount": {
"description": "TargetAmount is the amount the round is raising, recorded verbatim on the\ncanonical cap table's rounds.create contract.",
"type": "number"
}
},
"type": "object"
},
"name": "post_v1_company_fundraise_round"
},
{
"description": "RequestSafe raises an e-signature request over documents already in the org's\ndata room — a SAFE, a convertible note, or any other fundraising paper.\nAvailable only after incorporation (stage company).",
"inputSchema": {
"$defs": {
"Signer": {
"properties": {
"email": {
"description": "Email is the address the signature request is sent to.",
"type": "string"
},
"name": {
"description": "Name is the recipient's name, as it appears on the signature request.",
"type": "string"
}
},
"type": "object"
}
},
"properties": {
"documentIds": {
"description": "DocumentIDs are data room document ids to raise a signature request over. Required.",
"items": {
"type": "string"
},
"type": "array"
},
"signers": {
"description": "Signers are the recipients, each a name and an email. Required.",
"items": {
"$ref": "#/$defs/Signer"
},
"type": "array"
}
},
"type": "object"
},
"name": "post_v1_company_fundraise_safe"
},
{
"description": "RecordGenesis seeds the canonical cap table with the founding allocation\n(stakeholders, a common share class, issued shares) and anchors the\ndeterministic equity-genesis root on-chain.\n\nIt is idempotent: once a root is recorded the cap table is NOT re-seeded, which\nwould double-issue founder share certificates. The root is persisted even when\nthe on-chain submit fails, because the root is the tamper-evident witness and\nmust not be recomputed on retry. Available only at the genesis stage.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "post_v1_company_genesis"
},
{
"description": "ImportCapTable reads an existing company's cap table from a Google Sheet and\nadds its stakeholders to the canonical cap table.\n\nThe first row is a header and columns are matched by name (case-insensitive):\nname and email are required, type/relationship/institution optional. A sheet\nwithout name and email columns, or with no usable data rows, is refused with\n400. Available only at the import stage.",
"inputSchema": {
"properties": {
"range": {
"description": "Range is an optional A1 range within the sheet; empty reads the default range.",
"type": "string"
},
"spreadsheetId": {
"description": "SpreadsheetID is a Google Sheets id. Required.",
"type": "string"
}
},
"type": "object"
},
"name": "post_v1_company_import_captable"
},
{
"description": "ImportDocuments ingests an existing company's corporate documents from a Google\nDrive folder into the org's data room. The import is shallow — sub-folders are\nskipped, not walked — and available only at the import stage.",
"inputSchema": {
"properties": {
"folderId": {
"description": "FolderID is a Google Drive folder id. Required.",
"type": "string"
}
},
"type": "object"
},
"name": "post_v1_company_import_documents"
},
{
"description": "StartKYC opens an identity-verification session for every founder with the\nwired provider and records each session's reference on the formation.\n\nA start is never a decision: any terminal status the provider reports at\ninquiry time is clamped back to pending, so the payment gate can never open\nhere. A terminal status arrives only from POST /v1/company/kyc/refresh (the\nprovider) or POST /v1/company/kyc/decision (a Hanzo platform reviewer).",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "post_v1_company_kyc"
},
{
"description": "DecideKYC records a privileged reviewer's MANUAL decision on a founder's KYC —\nthe human-in-the-loop path, and the ONLY route to a pass when no real provider\nis wired. It produces a DISTINCT reviewer_confirmed, never a provider\n\"verified\".\n\nBecause Hanzo forms the entity and carries the formation KYC/AML obligation,\nthe reviewer is a HANZO platform reviewer (SuperAdmin), and the decision is\nATTRIBUTED to them.",
"inputSchema": {
"properties": {
"email": {
"description": "Email identifies the founder on the formation.",
"type": "string"
},
"status": {
"description": "Status is the decision: reviewer_confirmed or failed. Nothing else is accepted.",
"type": "string"
}
},
"type": "object"
},
"name": "post_v1_company_kyc_decision"
},
{
"description": "RefreshKYC reconciles each pending founder's KYC with the WIRED provider — the\nPULL path to a provider-reported terminal status. For the manual provider the\ncheck stays pending; for a real provider it reflects the settled decision,\nATTRIBUTED to the provider.\n\nIt NEVER trusts a client-asserted status — the status comes from the provider\nseam — so a client cannot force a pass here, and an already-passing founder\n(e.g. a reviewer confirmation) is left untouched.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "post_v1_company_kyc_refresh"
},
{
"description": "Skip marks the org as already incorporated and moves it onto the import path,\nso an existing company brings its documents and cap table in instead of forming\na new entity. Available only at the structure stage.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "post_v1_company_skip"
},
{
"description": "SetStructure records the entity kind, the state of formation and the proposed\nname. Available only at the structure stage; an unknown structure or\njurisdiction, or an empty name, is refused with 400.",
"inputSchema": {
"properties": {
"jurisdiction": {
"description": "Jurisdiction is the state of formation: DE or WY.",
"type": "string"
},
"name": {
"description": "Name is the proposed company name.",
"type": "string"
},
"structure": {
"description": "Structure is the legal entity: c-corp, llc or dao-llc.",
"type": "string"
}
},
"type": "object"
},
"name": "put_v1_company_structure"
}
]
+260
View File
@@ -0,0 +1,260 @@
[
{
"description": "ListAccreditation returns the org's tracked accreditation-state records, newest\nfirst — evidence entries the org keeps, never a platform certification.",
"inputSchema": {
"properties": {
"limit": {
"description": "Limit caps the rows returned; non-positive means the server default.",
"type": "integer"
}
},
"type": "object"
},
"name": "get_v1_compliance_accreditation"
},
{
"description": "GetAccreditation returns one tracked accreditation record.",
"inputSchema": {
"properties": {
"id": {
"description": "ID is the accreditation record to read, from the path.",
"type": "string"
}
},
"type": "object"
},
"name": "get_v1_compliance_accreditation_id"
},
{
"description": "AuditRead is the compliance-scoped read of the SHARED tamper-evident audit plane —\nthe SOC 2 posture surface (privileged actions: who started/decided what, when). The\norg is PINNED to the caller's validated org and the rows are narrowed to\ncompliance.* actions. Fail-closed: no principal is a 403, no configured audit\nstore a 501.",
"inputSchema": {
"properties": {
"result": {
"description": "Result filters rows by outcome result: success, deny, or error; empty means all.",
"type": "string"
}
},
"type": "object"
},
"name": "get_v1_compliance_audit"
},
{
"description": "Health reports subsystem liveness and the wired verification provider. Fail-open\non purpose: it never probes the external provider, so a provider outage cannot\nfail liveness.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "get_v1_compliance_health"
},
{
"description": "ListRecords is the unified compliance-record view for the org: its verifications\nand accreditation records together, each provider-reported or tracked, never\nplatform-asserted. PII stays in the subject store; records carry only opaque ids\nand statuses.",
"inputSchema": {
"properties": {
"limit": {
"description": "Limit caps the rows returned; non-positive means the server default.",
"type": "integer"
}
},
"type": "object"
},
"name": "get_v1_compliance_records"
},
{
"description": "Status is the org's honest posture read: the wired provider and the per-status\ntally of its verifications. It is deliberately NOT a boolean \"compliant\" — it\nreports counts of provider-reported states and carries the boundary disclaimer.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "get_v1_compliance_status"
},
{
"description": "ListSubjects returns the org's subjects as PII-MINIMIZED summaries — no name or\nemail, only whether an email is on file. The full record is returned only by the\nexplicit single-subject read.",
"inputSchema": {
"properties": {
"limit": {
"description": "Limit caps the rows returned; non-positive means the server default.",
"type": "integer"
}
},
"type": "object"
},
"name": "get_v1_compliance_subjects"
},
{
"description": "GetSubject returns one subject WITH its contact PII — the only surface that\nreturns it, and only to the owning org. The response is never cached by any\nintermediary.",
"inputSchema": {
"properties": {
"id": {
"description": "ID is the subject to read, from the path.",
"type": "string"
}
},
"type": "object"
},
"name": "get_v1_compliance_subjects_id"
},
{
"description": "ListVerifications returns the org's KYC/KYB verifications, newest first — opaque\nsubject references and provider-reported statuses only, no subject PII.",
"inputSchema": {
"properties": {
"limit": {
"description": "Limit caps the rows returned; non-positive means the server default.",
"type": "integer"
}
},
"type": "object"
},
"name": "get_v1_compliance_verifications"
},
{
"description": "GetVerification returns one verification — its opaque subject reference and\nprovider-reported status, no subject PII.",
"inputSchema": {
"properties": {
"id": {
"description": "ID is the verification to act on, from the path.",
"type": "string"
}
},
"type": "object"
},
"name": "get_v1_compliance_verifications_id"
},
{
"description": "CreateAccreditation records an ASSERTED accreditation state for a subject — the\nsubject's own assertion, with no verifier. Every CONFIRMED state\n(provider_verified, reviewer_confirmed) and every rejected/expired state is a\nDECISION recorded via the decision endpoint, attributed to the reviewer — a\ncreate can never stamp a confirmation. The underlying figures (income, net\nworth) are never stored; only the method, category, and state.",
"inputSchema": {
"properties": {
"basis": {
"description": "Basis is the qualification category: income, net_worth, professional_license,\nor entity.",
"type": "string"
},
"evidenceDocId": {
"description": "EvidenceDocID references an evidence document in the org's sealed data room.",
"type": "string"
},
"expiresAt": {
"description": "ExpiresAt is the unix second a confirmation ages out; 0 means none.",
"type": "integer"
},
"method": {
"description": "Method is how the state was established: self_attested, third_party_letter,\nor provider_verified.",
"type": "string"
},
"note": {
"description": "Note is a non-PII operator note.",
"type": "string"
},
"status": {
"description": "Status may only be \"asserted\" (empty reads as asserted); every confirmed,\nrejected or expired state is recorded via the decision endpoint.",
"type": "string"
},
"subjectId": {
"description": "SubjectID names the subject this record is about; it must exist within the org.",
"type": "string"
}
},
"type": "object"
},
"name": "post_v1_compliance_accreditation"
},
{
"description": "DecideAccreditation records an org reviewer's decision on an accreditation\nrecord — a reviewer confirmation, a provider verification the reviewer has\nevidence of (a CPA/attorney letter, a verifier report), a rejection, or an\nexpiry. ROLE-GATED (an org admin or platform reviewer) and ATTRIBUTED: the\nreviewer's identity is recorded as ReviewerSub and audited. Human-in-the-loop:\nthe platform never confirms on its own, and even a provider_verified state\ncarries the reviewer who recorded it.",
"inputSchema": {
"properties": {
"id": {
"description": "ID is the accreditation record to decide, from the path.",
"type": "string"
},
"status": {
"description": "Status is the decision being recorded: reviewer_confirmed, provider_verified,\nrejected, or expired.",
"type": "string"
}
},
"type": "object"
},
"name": "post_v1_compliance_accreditation_id_decision"
},
{
"description": "CreateSubject records a party the org is verifying as part of its own\nonboarding/compliance — a team member, vendor, customer, or counterparty. The\nsubject's contact PII (name/email) is sealed at rest and returned only to the\nowning org; downstream records reference the subject by opaque id.",
"inputSchema": {
"properties": {
"email": {
"description": "Email is the subject's contact email, sealed at rest.",
"type": "string"
},
"kind": {
"description": "Kind is the party type: \"individual\" (KYC) or \"business\" (KYB).",
"type": "string"
},
"name": {
"description": "Name is the subject's name, sealed at rest.",
"type": "string"
},
"ref": {
"description": "Ref is the org's own opaque external id for this subject.",
"type": "string"
}
},
"type": "object"
},
"name": "post_v1_compliance_subjects"
},
{
"description": "StartVerification begins a KYC/KYB verification of a subject through the wired\nprovider — an existing subject by id, or one created inline from the request.\nThe returned status is provider-reported and never terminal on a fresh start:\nstarting a verification can never yield a verified record, and a provider error\nis a 502, never a verification.",
"inputSchema": {
"properties": {
"email": {
"description": "Email is an inline subject's contact email, sealed at rest.",
"type": "string"
},
"kind": {
"description": "Kind is an inline subject's party type: \"individual\" (KYC) or \"business\" (KYB).",
"type": "string"
},
"name": {
"description": "Name is an inline subject's name, sealed at rest.",
"type": "string"
},
"ref": {
"description": "Ref is the org's own opaque external id for an inline subject.",
"type": "string"
},
"subjectId": {
"description": "SubjectID names an existing subject to verify; empty creates one inline.",
"type": "string"
}
},
"type": "object"
},
"name": "post_v1_compliance_verifications"
},
{
"description": "DecideVerification records a privileged reviewer's MANUAL decision on a\nverification — the human-in-the-loop path, and the ONLY route to a passing status\nwhen no real provider is wired. It produces a DISTINCT reviewer_confirmed, never\na provider_verified (a provider decision is the provider's to report, via the\nwebhook or a reconcile), and it is ROLE-GATED (an org admin or platform reviewer)\nAND ATTRIBUTED (the reviewer's user id is DecidedBy), so a manual pass is always\naccountable.",
"inputSchema": {
"properties": {
"id": {
"description": "ID is the verification to decide, from the path.",
"type": "string"
},
"status": {
"description": "Status is the reviewer's decision: \"reviewer_confirmed\" (a pass) or\n\"manual_review\" (withheld for review) — never a provider status.",
"type": "string"
}
},
"type": "object"
},
"name": "post_v1_compliance_verifications_id_decision"
},
{
"description": "RefreshVerification polls the wired provider for its current decision and\nrecords it, ATTRIBUTED to the provider — the internal PULL reconcile. For the\nManual provider the check stays pending; for a hosted provider it reflects the\nprovider's settled status. A poll error is a 502, never a verification.",
"inputSchema": {
"properties": {
"id": {
"description": "ID is the verification to act on, from the path.",
"type": "string"
}
},
"type": "object"
},
"name": "post_v1_compliance_verifications_id_refresh"
}
]
+87
View File
@@ -0,0 +1,87 @@
[
{
"description": "GetBoard aggregates the caller org's marketing content across every publishable\ncontent type into ONE queue board — the cross-type read the framework's\nper-DocType list cannot give. It never fails on a partial outage: a content type\nthe org has not installed, or one whose search errors, is skipped and logged\nrather than failing the whole board.",
"inputSchema": {
"properties": {
"doctype": {
"description": "DocType keeps only one content type; omitted, the board spans every\npublishable type. An unknown type is refused.",
"type": "string"
},
"limit": {
"description": "Limit caps the rows returned, clamped to 1000. Defaults to 200, which is also\nwhat a non-positive or unparseable value takes.",
"type": "integer"
},
"project": {
"description": "Project keeps only items in one brand/site sub-scope.",
"type": "string"
},
"status": {
"description": "Status keeps only items in one lifecycle state (draft, in_review, approved,\nqueued, published, archived). An undefined state is refused.",
"type": "string"
}
},
"type": "object"
},
"name": "get_v1_content_board"
},
{
"description": "GetChannels lists the distribution channels the caller's org has connected — the\nsocial integrations a publish can target. A deployment with no distribution edge\nwired answers 503 rather than an empty list that would read as \"no channels\".",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "get_v1_content_channels"
},
{
"description": "GetLifecycle returns the ONE marketing-content state machine: the ordered\nlifecycle states, which state a fresh document starts in, which one is publicly\nlive, and the legal successors of every state. The console builds its board\ncolumns and its per-item action buttons from this single answer, so the UI and\nthe write-time enforcement hook can never disagree about what is legal.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "get_v1_content_lifecycle"
},
{
"description": "PostTransition moves one content item to a new lifecycle state and, on the move to\npublished, fans it out to the item's channels. The edge must be legal for the\nitem's current state — an illegal move is refused with 409 — and the status write\nre-validates it at the storage boundary. Distribution is best effort: its honest\nstate is reported on the result and a distribution failure never rolls the status\nchange back.",
"inputSchema": {
"properties": {
"doctype": {
"description": "DocType is the content type to act on, from the path.",
"type": "string"
},
"name": {
"description": "Name is the document to act on, from the path.",
"type": "string"
},
"scheduleAt": {
"description": "ScheduleAt is an ISO-8601 go-live time handed to the channel's own scheduler;\n\"\" distributes now.",
"type": "string"
},
"to": {
"description": "To is the lifecycle state to move to. Required, and the move must be a legal\nedge from the item's current state.",
"type": "string"
}
},
"type": "object"
},
"name": "post_v1_content_doctype_name_transition"
},
{
"description": "Publish distributes one CMS content item to the channels recorded on it and\nreturns the honest per-channel outcome. The item names itself — its caption,\nmedia and channel list are read from the stored document, not from this request.\nIt is idempotent per channel (a channel already posted for this item is skipped),\nand a publish that loses the per-item lease to a live publisher answers status\n\"in_progress\" having posted nothing.",
"inputSchema": {
"properties": {
"doctype": {
"type": "string"
},
"name": {
"type": "string"
},
"scheduleAt": {
"description": "\"\" = now",
"type": "string"
}
},
"type": "object"
},
"name": "post_v1_content_publish"
}
]
+1
View File
@@ -0,0 +1 @@
[]
+476
View File
@@ -0,0 +1,476 @@
[
{
"description": "DeleteCompany removes one of the caller org's companies and answers 204. Any\ncontact or opportunity in the org that referenced it keeps existing with the\nreference cleared, so nothing is left pointing at a company that is gone.",
"inputSchema": {
"properties": {
"id": {
"description": "ID is the record to act on, from the path.",
"type": "string"
}
},
"type": "object"
},
"name": "delete_v1_crm_companies_id"
},
{
"description": "DeleteContact removes one of the caller org's contacts and answers 204. Any\nopportunity in the org that named it point of contact keeps existing with\nthat reference cleared.",
"inputSchema": {
"properties": {
"id": {
"description": "ID is the record to act on, from the path.",
"type": "string"
}
},
"type": "object"
},
"name": "delete_v1_crm_contacts_id"
},
{
"description": "DeleteOpportunity removes one of the caller org's deals and answers 204.",
"inputSchema": {
"properties": {
"id": {
"description": "ID is the record to act on, from the path.",
"type": "string"
}
},
"type": "object"
},
"name": "delete_v1_crm_opportunities_id"
},
{
"description": "ListApplications returns the org's Startup Program applications, newest first.\nEach carries its AI screen and its stage history; a stage narrows the page to\none pipeline stage.",
"inputSchema": {
"properties": {
"limit": {
"description": "Limit caps the rows returned: 200 by default, 1000 at most.",
"type": "integer"
},
"stage": {
"description": "Stage returns only the applications at that pipeline stage when set:\napplied, screened, qualified, credits-offered, onboarded or rejected.",
"type": "string"
}
},
"type": "object"
},
"name": "get_v1_crm_applications"
},
{
"description": "GetApplication returns one Startup Program application with its AI screen and stage history.\nAn id belonging to another org reads as not found.",
"inputSchema": {
"properties": {
"id": {
"description": "ID is the record to act on, from the path.",
"type": "string"
}
},
"type": "object"
},
"name": "get_v1_crm_applications_id"
},
{
"description": "ListCompanies returns the caller org's companies, most recently updated first.",
"inputSchema": {
"properties": {
"limit": {
"description": "Limit caps the rows returned: 200 by default, 1000 at most.",
"type": "integer"
}
},
"type": "object"
},
"name": "get_v1_crm_companies"
},
{
"description": "GetCompany returns one of the caller org's companies. An id belonging to\nanother org reads as not found.",
"inputSchema": {
"properties": {
"id": {
"description": "ID is the record to act on, from the path.",
"type": "string"
}
},
"type": "object"
},
"name": "get_v1_crm_companies_id"
},
{
"description": "ListContacts returns the caller org's contacts, most recently updated first.\nA companyId narrows the page to the people at that company.",
"inputSchema": {
"properties": {
"companyId": {
"description": "CompanyID returns only the contacts at that company when set.",
"type": "string"
},
"limit": {
"description": "Limit caps the rows returned: 200 by default, 1000 at most.",
"type": "integer"
}
},
"type": "object"
},
"name": "get_v1_crm_contacts"
},
{
"description": "GetContact returns one of the caller org's contacts. An id belonging to\nanother org reads as not found.",
"inputSchema": {
"properties": {
"id": {
"description": "ID is the record to act on, from the path.",
"type": "string"
}
},
"type": "object"
},
"name": "get_v1_crm_contacts_id"
},
{
"description": "ListOpportunities returns the caller org's deals, most recently updated first.\nA stage narrows the page to one pipeline stage.",
"inputSchema": {
"properties": {
"limit": {
"description": "Limit caps the rows returned: 200 by default, 1000 at most.",
"type": "integer"
},
"stage": {
"description": "Stage returns only the opportunities at that pipeline stage when set\n(NEW, SCREENING, MEETING, PROPOSAL or CUSTOMER; case-insensitive).",
"type": "string"
}
},
"type": "object"
},
"name": "get_v1_crm_opportunities"
},
{
"description": "GetOpportunity returns one of the caller org's deals. An id belonging to\nanother org reads as not found.",
"inputSchema": {
"properties": {
"id": {
"description": "ID is the record to act on, from the path.",
"type": "string"
}
},
"type": "object"
},
"name": "get_v1_crm_opportunities_id"
},
{
"description": "Summary counts the caller org's CRM records: companies, contacts, opportunities.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "get_v1_crm_summary"
},
{
"description": "PatchApplication moves one Startup Program application through the pipeline. The\nmove is recorded on the application's timeline, attributed to the calling\nstaff user: it may advance exactly one stage, go back to any earlier stage,\nreject from any non-rejected stage, or reopen a rejected application to\n`applied`; anything else is refused. Rejecting requires a reason. A note with\nno stage change is still recorded.",
"inputSchema": {
"properties": {
"id": {
"description": "ID is the application to move, from the path.",
"type": "string"
},
"note": {
"description": "Note is a free-text comment recorded on the timeline, with or without a\nstage change.",
"type": "string"
},
"reason": {
"description": "Reason records WHY, and is required to reject.",
"type": "string"
},
"stage": {
"description": "Stage is the stage to move to: applied, screened, qualified,\ncredits-offered, onboarded or rejected. Omit to leave the stage alone.",
"type": "string"
}
},
"type": "object"
},
"name": "patch_v1_crm_applications_id"
},
{
"description": "CreateCompany adds a company to the caller's org and answers 201 with the stored record.\nA name is required; an empty currency defaults to USD.",
"inputSchema": {
"properties": {
"arr": {
"description": "ARR is annual recurring revenue in minor units (cents) of Currency.",
"type": "integer"
},
"city": {
"description": "City is the head-office city.",
"type": "string"
},
"country": {
"description": "Country is the head-office country.",
"type": "string"
},
"currency": {
"description": "Currency is the ISO code ARR is denominated in; empty defaults to USD.",
"type": "string"
},
"domainName": {
"description": "DomainName is the company's primary domain, e.g. \"acme.com\".",
"type": "string"
},
"employees": {
"description": "Employees is the headcount.",
"type": "integer"
},
"id": {
"description": "ID names the company to update and comes from the path. A create ignores\nit: the server mints the id.",
"type": "string"
},
"idealCustomerProfile": {
"description": "ICP marks the company as an ideal-customer-profile fit.",
"type": "boolean"
},
"linkedinLink": {
"description": "Linkedin is the company's LinkedIn URL.",
"type": "string"
},
"name": {
"description": "Name is the company name. Required.",
"type": "string"
},
"xLink": {
"description": "XLink is the company's X (Twitter) URL.",
"type": "string"
}
},
"type": "object"
},
"name": "post_v1_crm_companies"
},
{
"description": "CreateContact adds a person to the caller's org and answers 201 with the stored record.\nOne of firstName, lastName or email is required, and a companyId must name a\ncompany in the same org.",
"inputSchema": {
"properties": {
"city": {
"description": "City is where the person is based.",
"type": "string"
},
"companyId": {
"description": "CompanyID links the contact to one of the org's companies.",
"type": "string"
},
"email": {
"description": "Email is the person's email address.",
"type": "string"
},
"firstName": {
"description": "FirstName is the person's given name.",
"type": "string"
},
"id": {
"description": "ID names the contact to update and comes from the path. A create ignores\nit: the server mints the id.",
"type": "string"
},
"jobTitle": {
"description": "JobTitle is the person's role at their company.",
"type": "string"
},
"lastName": {
"description": "LastName is the person's family name.",
"type": "string"
},
"linkedinLink": {
"description": "Linkedin is the person's LinkedIn URL.",
"type": "string"
},
"phone": {
"description": "Phone is the person's phone number.",
"type": "string"
},
"xLink": {
"description": "XLink is the person's X (Twitter) URL.",
"type": "string"
}
},
"type": "object"
},
"name": "post_v1_crm_contacts"
},
{
"description": "CreateOpportunity adds a deal to the caller's org and answers 201 with the stored record.\nA name is required; the stage defaults to NEW; companyId and pointOfContactId\nmust name records in the same org.",
"inputSchema": {
"properties": {
"amount": {
"description": "Amount is the deal value in minor units (cents) of Currency.",
"type": "integer"
},
"closeDate": {
"description": "CloseDate is the expected close, as a unix second (0 = unset).",
"type": "integer"
},
"companyId": {
"description": "CompanyID links the deal to one of the org's companies.",
"type": "string"
},
"currency": {
"description": "Currency is the ISO code Amount is denominated in; empty defaults to USD.",
"type": "string"
},
"id": {
"description": "ID names the opportunity to update and comes from the path. A create\nignores it: the server mints the id.",
"type": "string"
},
"name": {
"description": "Name is the deal name. Required.",
"type": "string"
},
"pointOfContactId": {
"description": "PointOfContact links the deal to one of the org's contacts.",
"type": "string"
},
"stage": {
"description": "Stage is the pipeline stage: NEW, SCREENING, MEETING, PROPOSAL or CUSTOMER\n(case-insensitive). Empty defaults to NEW.",
"type": "string"
}
},
"type": "object"
},
"name": "post_v1_crm_opportunities"
},
{
"description": "UpdateCompany replaces one of the caller org's companies. Every writable\nfield is taken from the request, so a field the request omits is CLEARED —\nsend the whole record. A name is required.",
"inputSchema": {
"properties": {
"arr": {
"description": "ARR is annual recurring revenue in minor units (cents) of Currency.",
"type": "integer"
},
"city": {
"description": "City is the head-office city.",
"type": "string"
},
"country": {
"description": "Country is the head-office country.",
"type": "string"
},
"currency": {
"description": "Currency is the ISO code ARR is denominated in; empty defaults to USD.",
"type": "string"
},
"domainName": {
"description": "DomainName is the company's primary domain, e.g. \"acme.com\".",
"type": "string"
},
"employees": {
"description": "Employees is the headcount.",
"type": "integer"
},
"id": {
"description": "ID names the company to update and comes from the path. A create ignores\nit: the server mints the id.",
"type": "string"
},
"idealCustomerProfile": {
"description": "ICP marks the company as an ideal-customer-profile fit.",
"type": "boolean"
},
"linkedinLink": {
"description": "Linkedin is the company's LinkedIn URL.",
"type": "string"
},
"name": {
"description": "Name is the company name. Required.",
"type": "string"
},
"xLink": {
"description": "XLink is the company's X (Twitter) URL.",
"type": "string"
}
},
"type": "object"
},
"name": "put_v1_crm_companies_id"
},
{
"description": "UpdateContact replaces one of the caller org's contacts. Every writable field\nis taken from the request, so a field the request omits is CLEARED — send the\nwhole record. One of firstName, lastName or email is required.",
"inputSchema": {
"properties": {
"city": {
"description": "City is where the person is based.",
"type": "string"
},
"companyId": {
"description": "CompanyID links the contact to one of the org's companies.",
"type": "string"
},
"email": {
"description": "Email is the person's email address.",
"type": "string"
},
"firstName": {
"description": "FirstName is the person's given name.",
"type": "string"
},
"id": {
"description": "ID names the contact to update and comes from the path. A create ignores\nit: the server mints the id.",
"type": "string"
},
"jobTitle": {
"description": "JobTitle is the person's role at their company.",
"type": "string"
},
"lastName": {
"description": "LastName is the person's family name.",
"type": "string"
},
"linkedinLink": {
"description": "Linkedin is the person's LinkedIn URL.",
"type": "string"
},
"phone": {
"description": "Phone is the person's phone number.",
"type": "string"
},
"xLink": {
"description": "XLink is the person's X (Twitter) URL.",
"type": "string"
}
},
"type": "object"
},
"name": "put_v1_crm_contacts_id"
},
{
"description": "UpdateOpportunity replaces one of the caller org's deals. Every writable\nfield is taken from the request, so a field the request omits is CLEARED —\nsend the whole record. A name is required and the stage must be a pipeline\nstage.",
"inputSchema": {
"properties": {
"amount": {
"description": "Amount is the deal value in minor units (cents) of Currency.",
"type": "integer"
},
"closeDate": {
"description": "CloseDate is the expected close, as a unix second (0 = unset).",
"type": "integer"
},
"companyId": {
"description": "CompanyID links the deal to one of the org's companies.",
"type": "string"
},
"currency": {
"description": "Currency is the ISO code Amount is denominated in; empty defaults to USD.",
"type": "string"
},
"id": {
"description": "ID names the opportunity to update and comes from the path. A create\nignores it: the server mints the id.",
"type": "string"
},
"name": {
"description": "Name is the deal name. Required.",
"type": "string"
},
"pointOfContactId": {
"description": "PointOfContact links the deal to one of the org's contacts.",
"type": "string"
},
"stage": {
"description": "Stage is the pipeline stage: NEW, SCREENING, MEETING, PROPOSAL or CUSTOMER\n(case-insensitive). Empty defaults to NEW.",
"type": "string"
}
},
"type": "object"
},
"name": "put_v1_crm_opportunities_id"
}
]
+1
View File
@@ -0,0 +1 @@
[]
+114
View File
@@ -0,0 +1,114 @@
[
{
"description": "ListDeployApplications returns the fleet as an argocd ApplicationList: one\nprojected Application per operator App CR, carrying the image tag the CR\nDECLARES, the tag actually RUNNING in the cluster's Deployment, the reconciled\nhealth, and the sync verdict those two produce (declared == running ⇒ Synced,\nboth known and different ⇒ OutOfSync, either unknown ⇒ Unknown).\n\nIt is TENANT-SCOPED: a platform SuperAdmin reads every platform namespace, a\nvalidated org member reads only its own org's tenant namespace and only the App\nCRs labelled with its org, and anyone else is refused. A cross-tenant CR is\nnever projected into an answer.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "get_v1_deploy_applications"
},
{
"description": "GetDeployApplication returns ONE projected argocd Application by name, with\nstatus.resources filled in from its reconciled resource tree — which is what\nmakes it the detail view rather than a row of the list.\n\nIt is TENANT-SCOPED, and a name that belongs to another org is reported NOT\nFOUND rather than refused: a 403 would confirm the application exists, so the\nroute would become a cross-tenant existence oracle. A name that is not a\nDNS-1123 label is a 400 before any cluster read.",
"inputSchema": {
"properties": {
"name": {
"description": "Name is the application to read, from the path. It must be a DNS-1123 label\n(lowercase alphanumerics and hyphens, starting and ending alphanumeric) —\nevery operator App CR's metadata.name satisfies that, and anything else is a\n400 rather than a lookup.",
"type": "string"
}
},
"type": "object"
},
"name": "get_v1_deploy_applications_name"
},
{
"description": "GetDeployResourceTree returns one application's argocd ApplicationTree: the\nobjects the operator reconciled from its App CR, reached by ownerRef — the\nDeployment and, under it, the ReplicaSet and Pods, plus the Service, Ingress,\nHorizontalPodAutoscaler, PodDisruptionBudget and ConfigMaps it owns — each node\ncarrying its parent edges and its health.\n\nSecrets are DELIBERATELY not walked, so no materialized environment can ever\nappear in the tree. Tenant-scoped exactly like the application read: another\norg's name is not found, a malformed name is a 400.",
"inputSchema": {
"properties": {
"name": {
"description": "Name is the application to read, from the path. It must be a DNS-1123 label\n(lowercase alphanumerics and hyphens, starting and ending alphanumeric) —\nevery operator App CR's metadata.name satisfies that, and anything else is a\n400 rather than a lookup.",
"type": "string"
}
},
"type": "object"
},
"name": "get_v1_deploy_applications_name_resource-tree"
},
{
"description": "GetDeployRevisionMetadata returns the argocd RevisionMetadata for one revision\nof one application — what the detail view shows beside a revision.\n\nAn App CR is IMAGE-pinned rather than commit-pinned: the deploy names an image\ntag, and the git source this projection reports is the display-only manifest\nrepo, not the application's own source. Nothing in this process can read a\ncommit's author or message for an arbitrary revision. So rather than 404 (which\nthe SPA turns into an error toast) or invent a git author, it answers the\nHONEST minimum: date is when the App CR was created, message is the revision\nasked for — with the empty revision and \"HEAD\" resolving to the image tag the\nCR declares — and author is empty. An over-long revision is truncated before it\nis echoed back.\n\nTenant-scoped exactly like the application read.",
"inputSchema": {
"properties": {
"name": {
"description": "Name is the application to read, from the path. It must be a DNS-1123 label.",
"type": "string"
},
"revision": {
"description": "Revision is the revision to describe, from the path. The empty revision and\n\"HEAD\" both mean \"whatever this application currently declares\".",
"type": "string"
}
},
"type": "object"
},
"name": "get_v1_deploy_applications_name_revisions_revision_metadata"
},
{
"description": "GetDeploySyncWindows returns one application's argocd\nApplicationSyncWindowState — the answer to \"is anything blocking a sync of this\napplication right now?\".\n\nThis platform runs NO sync windows, so the answer is always the permissive\nempty one: canSync true, with no active and no assigned windows. The\napplication is still resolved first, so a name that is not the caller's is not\nfound rather than handed the static body — the endpoint discloses nothing about\nanother tenant's fleet.",
"inputSchema": {
"properties": {
"name": {
"description": "Name is the application to read, from the path. It must be a DNS-1123 label\n(lowercase alphanumerics and hyphens, starting and ending alphanumeric) —\nevery operator App CR's metadata.name satisfies that, and anything else is a\n400 rather than a lookup.",
"type": "string"
}
},
"type": "object"
},
"name": "get_v1_deploy_applications_name_syncwindows"
},
{
"description": "ListDeployClusters returns the argocd ClusterList of the destinations the\ncaller's applications reconcile into: one entry per distinct destination\nserver, carrying the count of applications reconciling into it. The in-cluster\ndestination is always present, so an empty fleet still answers one cluster, and\nno cluster credential can appear — the projected type physically has no config\nfield.\n\nIt is TENANT-SCOPED and reads the SAME App CRs the applications list reads: a\nplatform SuperAdmin counts the whole fleet, a validated org member counts only\nits own org's applications, anyone else is refused.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "get_v1_deploy_clusters"
},
{
"description": "GetDeployGitOps lists every Hanzo CD Application in the cluster: the git source\neach one polls, the commit it last APPLIED, how its last sync operation ended,\nand its recent deploy history — newest deploy first, ordered by namespace then\nname.\n\nThis is the layer ABOVE the application board, and the two disagree in exactly\nthe case an operator most needs to see: main carries a new image pin, CD has\nnot applied that commit yet, so every App CR still declares the old tag and the\napplication board is legitimately \"Synced\" while the deploy has not landed.\nOnly the applied revision here can show that.\n\ninstalled is false — with a reason and an empty list — when the CD CRD is not\nserved in this cluster. That is a FACT about the cluster rather than a failure\nof the request, so the caller can say \"no CD plane here\" instead of rendering\nan error it cannot act on; a genuine transport or RBAC failure still errors.\n\nRead-only, and platform SuperAdmin only: the CD plane is fleet infrastructure\nwith no tenant dimension. This view observes CD and never drives it — the sync\npolicy is automated with self-heal, and the actionable verb an operator has is\nthe per-application reconcile at POST /v1/deploy/applications/{name}/sync.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "get_v1_deploy_gitops"
},
{
"description": "ListDeployProjects returns the argocd AppProjectList this console groups and\nfilters applications by. Projects are owned by Hanzo IAM rather than by argocd,\nso they are REFLECTED read-only from the IAM project store and nothing is\npersisted here: a validated org member gets its own organization's projects and\na platform SuperAdmin gets every organization's.\n\nA SuperAdmin whose IAM store is not reachable falls back to the real\nargoproj.io AppProject CRs when that CRD is served, and otherwise to one\npermissive synthesized project per distinct project name the App CRs declare.\nA project named \"default\" is always present, because that is what an App CR\ncarrying no project label projects to.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "get_v1_deploy_projects"
},
{
"description": "GetDeploySession answers \"is this browser signed in, and if not where does it\nsign in?\" — the dashboard SPA's bootstrap question, and the only route on this\nplane that answers for an anonymous caller.\n\nThe anonymous answer carries loggedIn:false and a URL and NOTHING else: no\nusername, no org, no groups, no issuer, no hint about who the caller might be or\nwhat exists in the cluster. Answering it costs nothing (the caller already knows\nwhether it holds a cookie) and withholding it costs the whole sign-in journey.\n\nThe predicate is the platform SuperAdmin fact — the SAME one every other route\nhere gates on, minted from a validated principal whose org is the reserved admin\norg — so a validated-but-not-SuperAdmin caller is reported as NOT signed in,\nwhich is the truth as this console defines it: they cannot use it.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "get_v1_deploy_session_userinfo"
},
{
"description": "GetDeploySettings returns the argocd AuthSettings object the dashboard SPA\nawaits before its first render.\n\nEvery value is a CONSTANT of this projection rather than configuration read\nfrom anywhere: the SPA's own login form is reported disabled and its OIDC\nconfig null because Hanzo IAM owns identity at the edge and this console's\nsign-in is GET /v1/deploy/login, and every argocd feature the projection does\nnot implement — status badges, Dex connectors, config-management plugins,\nkustomize versions, the exec terminal, apps-in-any-namespace, the hydrator,\nsync-with-replace — is reported off. Platform SuperAdmin only.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "get_v1_deploy_settings"
},
{
"description": "GetDeployVersion returns the argocd VersionMessage the dashboard SPA reads at\nbootstrap. There is no argocd binary behind this plane — it is a projection\nover operator App CRs — so the fields say so rather than describing a build:\nVersion names the projection, BuildDate is the moment this response was\ngenerated, and Compiler/Platform/GoVersion are the constants the SPA tolerates\nrather than facts about this process. Platform SuperAdmin only.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "get_v1_deploy_version"
}
]
+49
View File
@@ -0,0 +1,49 @@
[
{
"description": "disconnect forgets a destination for the caller's org: every credential held in\nKMS, then the stored config. Idempotent, and it requires org admin.",
"inputSchema": {
"properties": {
"platform": {
"description": "Platform is the destination to act on, from the path: ga4 | meta | tiktok |\nlinkedin | x | reddit | posthog | umami.",
"type": "string"
}
},
"type": "object"
},
"name": "delete_v1_destinations_platform"
},
{
"description": "list reports every destination this deployment can forward to, each with the\ncaller org's connection state: whether it is connected, whether it is enabled,\nwhether a credential resolves right now, and the config fields the console\nrenders for it.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "get_v1_destinations"
},
{
"description": "get reports one destination's card for the caller's org — its config fields,\nits connection state, and whether a credential resolves right now. A platform\nthis deployment does not carry is not found.",
"inputSchema": {
"properties": {
"platform": {
"description": "Platform is the destination to act on, from the path: ga4 | meta | tiktok |\nlinkedin | x | reddit | posthog | umami.",
"type": "string"
}
},
"type": "object"
},
"name": "get_v1_destinations_platform"
},
{
"description": "test sends ONE synthetic pageview through the connected destination end to end\nand reports what the platform said. A send the platform refuses is reported as\ndata — {\"ok\": false, \"error\": …} at 200 — so the console shows the platform's\nown words rather than an error about Hanzo. It requires org admin.",
"inputSchema": {
"properties": {
"platform": {
"description": "Platform is the destination to act on, from the path: ga4 | meta | tiktok |\nlinkedin | x | reddit | posthog | umami.",
"type": "string"
}
},
"type": "object"
},
"name": "post_v1_destinations_platform_test"
}
]
+1
View File
@@ -0,0 +1 @@
[]
+146
View File
@@ -0,0 +1,146 @@
[
{
"description": "DeleteLoadBalancer removes one of the caller org's load balancers and answers\n204. Ownership is confirmed by re-fetching the resource before anything is\ndeleted, so a cross-tenant id is a 404 rather than a delete of another org's\nload balancer.",
"inputSchema": {
"properties": {
"id": {
"description": "ID is the DigitalOcean resource id (a UUID), from the path.",
"type": "string"
}
},
"type": "object"
},
"name": "delete_v1_load-balancers_id"
},
{
"description": "DeleteVpc removes one of the caller org's VPCs and answers 204. Ownership is\nconfirmed by re-fetching the resource and checking its physical name carries\nthe caller's org prefix BEFORE anything is deleted, so a cross-tenant id is a\n404 rather than a delete of another org's VPC.",
"inputSchema": {
"properties": {
"id": {
"description": "ID is the DigitalOcean resource id (a UUID), from the path.",
"type": "string"
}
},
"type": "object"
},
"name": "delete_v1_vpcs_id"
},
{
"description": "ListLoadBalancers returns every load balancer the caller's org owns, under the\nfriendly names the org created them with. Same account-wide filter as the VPC\nlisting: a load balancer outside the caller's \"o\"\u003corgHash\u003e- namespace is never\nin the answer.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "get_v1_load-balancers"
},
{
"description": "GetLoadBalancer returns one of the caller org's load balancers by id. One that\nexists in another org's namespace is reported 404, never 403 — the same\nexistence-oracle guard the VPC read applies.",
"inputSchema": {
"properties": {
"id": {
"description": "ID is the DigitalOcean resource id (a UUID), from the path.",
"type": "string"
}
},
"type": "object"
},
"name": "get_v1_load-balancers_id"
},
{
"description": "ListVpcs returns every VPC the caller's org owns, under the friendly names the\norg created them with. DigitalOcean is one account for the whole deployment, so\nthe account-wide inventory is filtered to the caller's own \"o\"\u003corgHash\u003e- name\nprefix and the prefix is stripped — another org's VPC is not merely hidden, it\nis never in the answer.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "get_v1_vpcs"
},
{
"description": "GetVpc returns one of the caller org's VPCs by id. A VPC that exists but sits\nin another org's namespace is reported 404, never 403 — the answer must not\ntell one tenant that another tenant's resource exists.",
"inputSchema": {
"properties": {
"id": {
"description": "ID is the DigitalOcean resource id (a UUID), from the path.",
"type": "string"
}
},
"type": "object"
},
"name": "get_v1_vpcs_id"
},
{
"description": "CreateLoadBalancer creates a load balancer in the caller's org namespace and\nanswers 201 with it. The physical DigitalOcean name is derived server-side from\nthe validated org; a name that already exists there is a 409. Omitting\nforwarding rules yields a usable HTTP 80→80 load balancer rather than a 422.",
"inputSchema": {
"$defs": {
"fwdRule": {
"properties": {
"entry_port": {
"description": "EntryPort is the port the load balancer listens on.",
"type": "integer"
},
"entry_protocol": {
"description": "EntryProtocol is the protocol the load balancer listens with (http, https, tcp).",
"type": "string"
},
"target_port": {
"description": "TargetPort is the backend port traffic is forwarded to.",
"type": "integer"
},
"target_protocol": {
"description": "TargetProtocol is the protocol used to reach the backend droplets.",
"type": "string"
}
},
"type": "object"
}
},
"properties": {
"forwarding_rules": {
"description": "ForwardingRules are the listen→backend port mappings. Empty defaults to\nplain HTTP 80→80, the same default DigitalOcean's own console applies.",
"items": {
"$ref": "#/$defs/fwdRule"
},
"type": "array"
},
"name": {
"description": "Name is the FRIENDLY name, a DNS-safe slug of at most 40 characters. The\nphysical DigitalOcean name is derived from it and the caller's org.",
"type": "string"
},
"region": {
"description": "Region is the DigitalOcean region slug (nyc3, sfo3, …). Required.",
"type": "string"
},
"size": {
"description": "Size is the DigitalOcean size slug. Empty takes DO's default.",
"type": "string"
},
"type": {
"description": "Type is the DigitalOcean load-balancer type. Empty takes DO's default (REGIONAL).",
"type": "string"
}
},
"type": "object"
},
"name": "post_v1_load-balancers"
},
{
"description": "CreateVpc creates a VPC in the caller's org namespace and answers 201 with it.\nThe physical DigitalOcean name is derived server-side from the validated org,\nso a tenant can only ever create inside its own namespace; a name that already\nexists there is a 409.",
"inputSchema": {
"properties": {
"ip_range": {
"description": "IPRange is the VPC's private CIDR. Empty lets DigitalOcean assign one.",
"type": "string"
},
"name": {
"description": "Name is the FRIENDLY name, a DNS-safe slug of at most 40 characters. The\nphysical DigitalOcean name is derived from it and the caller's org.",
"type": "string"
},
"region": {
"description": "Region is the DigitalOcean region slug (nyc3, sfo3, …). Required.",
"type": "string"
}
},
"type": "object"
},
"name": "post_v1_vpcs"
}
]
+1
View File
@@ -0,0 +1 @@
[]
+40
View File
@@ -0,0 +1,40 @@
// Package plugin carries the fleet's build-time MCP catalogues into the host.
//
// A plugin's tool list is a function of its typed-op registry, so it is known
// when the plugin is BUILT: `<app> describe plugin/<app>` writes mcp.json beside
// openapi.json from one mount of one router (describe.go). This package embeds
// those files and hands each one to zip as Plugin.Tools, which is what lets the
// host answer tools/list for all 112 subsystems without starting a single one —
// the invariant the whole lazy fleet rests on, since MCPTools() is in-process and
// a host cannot ask a plugin that is not running.
//
// It exists as its own leaf package for one reason: go:embed cannot reach outside
// its own directory, so the bytes must be embedded from HERE, and cmd/cloud must
// stay light. This file imports embed and nothing else — no apps, no cloud root —
// so `go list -deps ./cmd/cloud` gains exactly one package and still links no
// subsystem.
//
// The catalogue can only be INCOMPLETE, never wrong: the child's own registry
// answers the call, so a name the host still lists but the child no longer serves
// yields that child's -32602 rather than a mis-dispatch.
package plugin
import "embed"
// catalogues holds every app's mcp.json. The pattern is a glob, so an app that
// has not been described yet is simply absent — Tools returns nil and zip leaves
// that plugin off the door — rather than a build failure in the host.
//
//go:embed */mcp.json
var catalogues embed.FS
// Tools is app's MCP catalogue: the JSON array its own App.MCPTools() projected
// at build time, ready to hand to zip.Plugin.Tools. Nil for an app that ships
// none, which is exactly how a plugin opts out of the composed door.
func Tools(app string) []byte {
b, err := catalogues.ReadFile(app + "/mcp.json")
if err != nil {
return nil
}
return b
}
+41
View File
@@ -0,0 +1,41 @@
[
{
"description": "Projection reports which console apps the CALLER's org may open, and the plan slug\nthat decides it. It is the READ side of the unified paywall: the org's plan tier\nresolved from commerce, which is a different authority from the enablement store\nbehind GET /v1/orgs/{org}/entitlements (that one is the org's own on/off intent).\n\nIt fails SAFE-TO-LOCKED, never 500: an unvalidated principal is a 403, but a\ncommerce outage reports every app locked at 200 rather than breaking the shell.\nThe ENFORCEMENT path still fails open, so functionality survives the same outage\neven while the UI conservatively shows locked.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "get_v1_entitlements"
},
{
"description": "Get lists the products an org has ENABLED — its own intent, which the console's\npaid-product sidebar reads to decide what to show. It is distinct from what the\norg's plan ENTITLES it to (that is GET /v1/entitlements, resolved from commerce).\n\nA caller may only read its OWN org's row; a platform super admin may read any.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "get_v1_orgs_org_entitlements"
},
{
"description": "Post turns products on or off for an org and returns the enabled set afterwards.\n\nA product may only be ENABLED if the org's plan already ENTITLES it, so enabling\nnever spends new money — a product the plan does not grant answers 402 and the\nconsole routes that to an upgrade prompt. DISABLING is never gated. A platform\nsuper admin bypasses the plan check (operator comp/grant) and may target any org;\neveryone else may only change their own. Commerce unreachable is a 503, never an\nimplicit yes.",
"inputSchema": {
"properties": {
"add": {
"description": "Add is the product ids to turn ON. Each must already be an ACTIVE entitlement\nof the org's plan, unless the caller is a platform super admin.",
"items": {
"type": "string"
},
"type": "array"
},
"remove": {
"description": "Remove is the product ids to turn OFF. Disabling is never gated.",
"items": {
"type": "string"
},
"type": "array"
}
},
"type": "object"
},
"name": "post_v1_orgs_org_entitlements"
}
]
+1
View File
@@ -0,0 +1 @@
[]
+1
View File
@@ -0,0 +1 @@
[]
+1
View File
@@ -0,0 +1 @@
[]
+1
View File
@@ -0,0 +1 @@
[]
+100
View File
@@ -0,0 +1,100 @@
[
{
"description": "DeleteFlagDefinition removes one flag definition by key and records the\ndeletion in the change log. A key the caller's store does not hold is a 404.",
"inputSchema": {
"properties": {
"key": {
"description": "Key is the flag key to act on, from the path.",
"type": "string"
}
},
"type": "object"
},
"name": "delete_v1_flags_defs_key"
},
{
"description": "ListFlagActivity returns the caller's flag change log newest-first: every\ncreate, update and delete, with the actor and the time.",
"inputSchema": {
"properties": {
"limit": {
"description": "Limit caps the rows returned. 1500; anything else takes the default 100.",
"type": "integer"
}
},
"type": "object"
},
"name": "get_v1_flags_activity"
},
{
"description": "ListFlagDefinitions returns every flag definition in the caller's (org,\nproject) store, by key, with its version and who last changed it.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "get_v1_flags_defs"
},
{
"description": "GetFlagDefinition returns one flag definition by key, or 404 when the caller's\nstore has none under that key.",
"inputSchema": {
"properties": {
"key": {
"description": "Key is the flag key to act on, from the path.",
"type": "string"
}
},
"type": "object"
},
"name": "get_v1_flags_defs_key"
},
{
"description": "Health reports that the flag engine is serving. It is not gated: liveness must\nbe probe-able without a token.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "get_v1_flags_health"
},
{
"description": "Evaluate runs the caller's flag definitions for one identity and returns the\nPostHog-shaped verdict: which flags are on (or which variant), their payloads,\nand whether any definition failed to compute. Evaluation is in-process over the\ncaller's own (org, project) definitions — no network hop, no shared KV — so a\ntenant can only ever evaluate its own flags.",
"inputSchema": {
"properties": {
"distinct_id": {
"description": "DistinctID is the identity the flags are evaluated for. Required.",
"type": "string"
},
"groups": {
"description": "Groups are the group-level properties, keyed by group type index."
},
"person_properties": {
"description": "PersonProperties are the person-level properties conditions match against."
}
},
"type": "object"
},
"name": "post_v1_flags"
},
{
"description": "Evaluate runs the caller's flag definitions for one identity and returns the\nPostHog-shaped verdict: which flags are on (or which variant), their payloads,\nand whether any definition failed to compute. Evaluation is in-process over the\ncaller's own (org, project) definitions — no network hop, no shared KV — so a\ntenant can only ever evaluate its own flags.",
"inputSchema": {
"properties": {
"distinct_id": {
"description": "DistinctID is the identity the flags are evaluated for. Required.",
"type": "string"
},
"groups": {
"description": "Groups are the group-level properties, keyed by group type index."
},
"person_properties": {
"description": "PersonProperties are the person-level properties conditions match against."
}
},
"type": "object"
},
"name": "post_v1_flags_decide"
},
{
"description": "PutFlagDefinition creates or replaces the flag definition at the path's key and\nreturns the stored row. The BODY IS THE DEFINITION DOCUMENT — the PostHog-shaped\nJSON object the evaluator consumes — and it is stored verbatim except that its\n\"key\" is forced to the key in the URL, so a document can never be filed under a\nname other than the one it was addressed by. Every write bumps the version and\nappends to the change log under the caller's identity.",
"inputSchema": {},
"name": "put_v1_flags_defs_key"
}
]
+437
View File
@@ -0,0 +1,437 @@
[
{
"description": "deleteDocument removes one document, after its on_trash hooks agree. A\nSUBMITTED document cannot be deleted — cancel it first. Answers 204.",
"inputSchema": {
"properties": {
"doctype": {
"description": "DocType is the document's DocType, from the path.",
"type": "string"
},
"name": {
"description": "Name is the document's name — its key within the DocType — from the path.\nA name containing a space arrives percent-encoded and is decoded before it\nis matched against the stored one.",
"type": "string"
}
},
"type": "object"
},
"name": "delete_v1_framework_doctype_name"
},
{
"description": "deleteDocType removes a DocType and every document stored under it. The\ndefinition and its data go together — a document with no schema can be neither\nvalidated nor read back — so there is no undo. Manager-only. Answers 204.",
"inputSchema": {
"properties": {
"name": {
"description": "Name is the DocType's name, from the path. A name containing a space\n(\"Sales Invoice\") arrives percent-encoded and is decoded before it is\nmatched against the stored one.",
"type": "string"
}
},
"type": "object"
},
"name": "delete_v1_framework_doctypes_name"
},
{
"description": "revokeRole removes one (user, role) grant in the caller's org. Manager-only.\nAnswers 204; a grant that does not exist is not found.",
"inputSchema": {
"properties": {
"role": {
"description": "Role is the role to revoke, from the path. A role name containing a space\n(\"System Manager\") arrives percent-encoded and is decoded before it is\nmatched against the stored assignment.",
"type": "string"
},
"user": {
"description": "User is the assignee whose grant is being revoked, from the path.",
"type": "string"
}
},
"type": "object"
},
"name": "delete_v1_framework_roles_user_role"
},
{
"description": "listDocuments returns the caller org's documents of one DocType, filtered,\nordered and projected by the query. The DocType is resolved FIRST — through\nthe same permission gate the list itself uses — because the query is validated\nagainst its schema: a filter, sort or field name the DocType does not declare\nis refused rather than reaching the store.",
"inputSchema": {
"properties": {
"doctype": {
"description": "DocType is the DocType to list, from the path.",
"type": "string"
},
"fields": {
"description": "Fields projects the response to a subset — a JSON array [\"a\",\"b\"] or a\ncomma list \"a,b\". The envelope keys are always returned.",
"type": "string"
},
"filters": {
"description": "Filters is a JSON object of equality matches, e.g. {\"priority\":\"High\"}.\nEvery key must be a field the DocType declares (or the managed name /\ndocstatus); an undeclared one is refused rather than silently ignored.",
"type": "string"
},
"limit": {
"description": "Limit caps the rows returned. Anything that is not a positive integer\nleaves the engine's default in place.",
"type": "string"
},
"order_by": {
"description": "OrderBy is \"\u003cfield\u003e [asc|desc]\". Empty means most-recently-updated first.",
"type": "string"
}
},
"type": "object"
},
"name": "get_v1_framework_doctype"
},
{
"description": "getDocument returns one document by name, with Password fields redacted.",
"inputSchema": {
"properties": {
"doctype": {
"description": "DocType is the document's DocType, from the path.",
"type": "string"
},
"name": {
"description": "Name is the document's name — its key within the DocType — from the path.\nA name containing a space arrives percent-encoded and is decoded before it\nis matched against the stored one.",
"type": "string"
}
},
"type": "object"
},
"name": "get_v1_framework_doctype_name"
},
{
"description": "listDocTypes returns every DocType defined in the caller's org. Another\ntenant's definitions are never included: the org is part of the store key.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "get_v1_framework_doctypes"
},
{
"description": "getDocType returns one DocType definition — its fields, naming rule,\npermissions and lifecycle flags. Scoped to the caller's org, so another\ntenant's DocType of the same name is simply not found.",
"inputSchema": {
"properties": {
"name": {
"description": "Name is the DocType's name, from the path. A name containing a space\n(\"Sales Invoice\") arrives percent-encoded and is decoded before it is\nmatched against the stored one.",
"type": "string"
}
},
"type": "object"
},
"name": "get_v1_framework_doctypes_name"
},
{
"description": "listModules returns every app lane compiled into this deployment and the\nDocTypes each one installs. It describes the BINARY, not the org: what a given\norg has actually installed is the per-module state below.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "get_v1_framework_modules"
},
{
"description": "getModule returns one app lane's install state for the caller's org: the\nDocTypes the lane declares, and which of them already exist in the org. That\nis the honest \"set up\" versus \"installed\" answer a console renders.",
"inputSchema": {
"properties": {
"module": {
"description": "Module is the lane's registered name (\"cms\", \"erp\"), from the path.",
"type": "string"
}
},
"type": "object"
},
"name": "get_v1_framework_modules_module"
},
{
"description": "listRoles returns every (user, role) assignment in the caller's org. Roles are\nwhat DocType permissions are written against, so this is the grant table the\npermission calculus resolves a member's rights from.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "get_v1_framework_roles"
},
{
"description": "summary reports how much of the DocType surface the caller's org uses: how\nmany DocTypes it has defined, and how many documents exist across them.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "get_v1_framework_summary"
},
{
"description": "cancelDocument moves a submitted document to cancelled (docstatus 1 → 2) after\nits on_cancel hooks agree. Cancelling is terminal — a cancelled document\ncannot be re-submitted — but it CAN then be deleted.",
"inputSchema": {
"properties": {
"doctype": {
"description": "DocType is the document's DocType, from the path.",
"type": "string"
},
"name": {
"description": "Name is the document's name — its key within the DocType — from the path.\nA name containing a space arrives percent-encoded and is decoded before it\nis matched against the stored one.",
"type": "string"
}
},
"type": "object"
},
"name": "post_v1_framework_doctype_name_cancel"
},
{
"description": "submitDocument moves a draft to submitted (docstatus 0 → 1) after its\non_submit hooks agree. A submitted document is IMMUTABLE: further writes and\ndeletes are refused until it is cancelled. Only a submittable DocType has this\nlifecycle; any other docstatus is an illegal transition.",
"inputSchema": {
"properties": {
"doctype": {
"description": "DocType is the document's DocType, from the path.",
"type": "string"
},
"name": {
"description": "Name is the document's name — its key within the DocType — from the path.\nA name containing a space arrives percent-encoded and is decoded before it\nis matched against the stored one.",
"type": "string"
}
},
"type": "object"
},
"name": "post_v1_framework_doctype_name_submit"
},
{
"description": "createDocType defines a DocType in the caller's org: the metadata that gives a\ndocument surface its fields, its naming rule, whether it has a submit/cancel\nlifecycle, and which role may do what to it. Manager-only — on a fresh org the\nfirst caller to administer it is seeded as its System Manager, after which\nonly a System Manager (or a platform admin) may define. Answers 201.",
"inputSchema": {
"$defs": {
"DocField": {
"properties": {
"default": {
"type": "string"
},
"fetchFrom": {
"type": "string"
},
"fieldname": {
"type": "string"
},
"fieldtype": {
"type": "string"
},
"hidden": {
"type": "boolean"
},
"inListView": {
"type": "boolean"
},
"label": {
"type": "string"
},
"options": {
"type": "string"
},
"readOnly": {
"type": "boolean"
},
"reqd": {
"type": "boolean"
},
"unique": {
"type": "boolean"
}
},
"type": "object"
},
"DocPerm": {
"properties": {
"cancel": {
"type": "boolean"
},
"create": {
"type": "boolean"
},
"delete": {
"type": "boolean"
},
"read": {
"type": "boolean"
},
"role": {
"type": "string"
},
"submit": {
"type": "boolean"
},
"write": {
"type": "boolean"
}
},
"type": "object"
}
},
"properties": {
"autoname": {
"type": "string"
},
"createdAt": {
"type": "integer"
},
"fields": {
"items": {
"$ref": "#/$defs/DocField"
},
"type": "array"
},
"isSingle": {
"type": "boolean"
},
"isSubmittable": {
"type": "boolean"
},
"module": {
"type": "string"
},
"name": {
"type": "string"
},
"permissions": {
"items": {
"$ref": "#/$defs/DocPerm"
},
"type": "array"
},
"titleField": {
"type": "string"
},
"updatedAt": {
"type": "integer"
}
},
"type": "object"
},
"name": "post_v1_framework_doctypes"
},
{
"description": "installModule creates an app lane's DocTypes in the caller's org. Idempotent\nand create-if-absent: a DocType the org already has is reported as existing\nand never replaced, so re-installing cannot clobber a definition the org has\nsince edited. Manager-only.",
"inputSchema": {
"properties": {
"module": {
"description": "Module is the lane's registered name (\"cms\", \"erp\"), from the path.",
"type": "string"
}
},
"type": "object"
},
"name": "post_v1_framework_modules_module_install"
},
{
"description": "assignRole grants one user one role in the caller's org — how a member gains\nrights on a DocType, since permissions name roles and never users.\nManager-only. Answers 201.",
"inputSchema": {
"properties": {
"role": {
"type": "string"
},
"user": {
"type": "string"
}
},
"type": "object"
},
"name": "post_v1_framework_roles"
},
{
"description": "replaceDocType replaces a DocType definition wholesale (PUT semantics): the\nstored definition becomes the body. The name in the URL is authoritative over\nthe body's, and documents already stored under the DocType are left intact.\nManager-only.",
"inputSchema": {
"$defs": {
"DocField": {
"properties": {
"default": {
"type": "string"
},
"fetchFrom": {
"type": "string"
},
"fieldname": {
"type": "string"
},
"fieldtype": {
"type": "string"
},
"hidden": {
"type": "boolean"
},
"inListView": {
"type": "boolean"
},
"label": {
"type": "string"
},
"options": {
"type": "string"
},
"readOnly": {
"type": "boolean"
},
"reqd": {
"type": "boolean"
},
"unique": {
"type": "boolean"
}
},
"type": "object"
},
"DocPerm": {
"properties": {
"cancel": {
"type": "boolean"
},
"create": {
"type": "boolean"
},
"delete": {
"type": "boolean"
},
"read": {
"type": "boolean"
},
"role": {
"type": "string"
},
"submit": {
"type": "boolean"
},
"write": {
"type": "boolean"
}
},
"type": "object"
}
},
"properties": {
"autoname": {
"type": "string"
},
"createdAt": {
"type": "integer"
},
"fields": {
"items": {
"$ref": "#/$defs/DocField"
},
"type": "array"
},
"isSingle": {
"type": "boolean"
},
"isSubmittable": {
"type": "boolean"
},
"module": {
"type": "string"
},
"name": {
"type": "string"
},
"permissions": {
"items": {
"$ref": "#/$defs/DocPerm"
},
"type": "array"
},
"titleField": {
"type": "string"
},
"updatedAt": {
"type": "integer"
}
},
"type": "object"
},
"name": "put_v1_framework_doctypes_name"
}
]
+1
View File
@@ -0,0 +1 @@
[]
+64
View File
@@ -0,0 +1,64 @@
[
{
"description": "Read returns the EFFECTIVE edge policy the caller is subject to: the platform CORS\nallowlist and pre-auth per-IP flood cap in force, plus the caller's own authenticated\nrate ceiling, edge-cache TTLs and accepted-method allowlist. A SuperAdmin may inspect\na specific tenant's effective policy with ?org=\u003cslug\u003e.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "get_v1_gateway_config"
},
{
"description": "Write updates one policy scope and returns the policy in force after the write.\nA body carrying any PLATFORM field (cors_origins, per_ip_rpm, window_sec) is a\nplatform write and requires SuperAdmin; otherwise it is a per-org write (org_rpm,\ncache_ttl_sec, cache_paths, methods) scoped to the caller's own org — or, for a\nSuperAdmin, the tenant named by ?org=\u003cslug\u003e. A body that sets nothing is a 400.\nupdated_at and updated_by are server-stamped; a client-supplied value is ignored.",
"inputSchema": {
"properties": {
"cache_paths": {
"additionalProperties": {
"type": "integer"
},
"description": "CachePaths overrides CacheTTLSec per path PREFIX (key \"/v1/models\" → seconds).\nThe longest matching prefix wins.",
"type": "object"
},
"cache_ttl_sec": {
"description": "CacheTTLSec is the org's default edge-cache TTL for its responses, in seconds;\n0 means no caching. Unset inherits the platform default.",
"type": "integer"
},
"cors_origins": {
"description": "CORSOrigins is the PLATFORM-scope CORS allowlist EdgeCORS admits: an exact\norigin, a bare host, or a \"*.host\" wildcard. Writable only by a SuperAdmin —\nCORS is evaluated before identity, so it has no tenant to scope to.",
"items": {
"type": "string"
},
"type": "array"
},
"methods": {
"description": "Methods is the allowlist of HTTP methods the edge accepts for this org. Empty\nmeans all are accepted.",
"items": {
"type": "string"
},
"type": "array"
},
"org_rpm": {
"description": "OrgRPM is the org's OWN authenticated rate ceiling, requests per minute, as\nScopeRateLimit enforces it. Unset inherits the platform default, then the\nstatic boot default.",
"type": "integer"
},
"per_ip_rpm": {
"description": "PerIPRPM is the PLATFORM-scope pre-auth flood cap: requests EdgeRateLimit\nadmits per WindowSec from one client IP. SuperAdmin-only, same reason.",
"type": "integer"
},
"updated_at": {
"description": "UpdatedAt is the unix second this policy row was last written. Server-stamped;\na client-supplied value is ignored.",
"type": "integer"
},
"updated_by": {
"description": "UpdatedBy is the validated user id that wrote this policy row. Server-stamped;\na client-supplied value is ignored.",
"type": "string"
},
"window_sec": {
"description": "WindowSec is the window PerIPRPM is counted over, in seconds. SuperAdmin-only.",
"type": "integer"
}
},
"type": "object"
},
"name": "put_v1_gateway_config"
}
]
+428
View File
@@ -0,0 +1,428 @@
[
{
"description": "deleteKey removes a registered SSH key, scoped to the caller's org: an org can\nonly delete its own, and a key id it does not own is not found. Answers 204\nwith no body. Once removed the key no longer authenticates any SSH git access.",
"inputSchema": {
"properties": {
"id": {
"description": "ID is the key's identifier (\"gitkey_…\"), from the :id path segment.",
"type": "string"
}
},
"type": "object"
},
"name": "delete_v1_git_keys_id"
},
{
"description": "deleteRepo removes a repo's metadata and purges its storage. Answers 204 with\nno body. The metadata row is the source of truth for existence, so a storage\npurge that fails is logged and the delete still succeeds — and a second call\nis a 404, not a second delete.",
"inputSchema": {
"properties": {
"name": {
"description": "Name is the repo's org-unique handle, from the :name path segment. A\ntrailing \".git\" is stripped.",
"type": "string"
}
},
"type": "object"
},
"name": "delete_v1_git_repos_name"
},
{
"description": "deleteMirror removes one outbound mirror target; later pushes stop being\nforwarded to it. Answers 204 with no body. Nothing is done to the downstream\nremote itself — only this repo's intent to push there is dropped.",
"inputSchema": {
"properties": {
"id": {
"description": "ID is the row to remove, from the :id path segment.",
"type": "string"
},
"name": {
"description": "Name is the repo, from the :name path segment.",
"type": "string"
}
},
"type": "object"
},
"name": "delete_v1_git_repos_name_mirrors_id"
},
{
"description": "unsubscribe removes one Slack subscription from a repo; the notifier stops\nposting that repo's events to that channel. Answers 204 with no body. An id\nthat is not this repo's subscription is not found.",
"inputSchema": {
"properties": {
"id": {
"description": "ID is the row to remove, from the :id path segment.",
"type": "string"
},
"name": {
"description": "Name is the repo, from the :name path segment.",
"type": "string"
}
},
"type": "object"
},
"name": "delete_v1_git_repos_name_subscriptions_id"
},
{
"description": "listKeys returns the SSH public keys registered to the caller's org — the keys\nthat authenticate `git clone git@\u003chost\u003e:\u003corg\u003e/\u003crepo\u003e.git`. Keys are org-scoped\non read even though the fingerprint index is global, so one org never sees\nanother's.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "get_v1_git_keys"
},
{
"description": "listRepos returns the repos in the caller's scope, most recently updated\nfirst. The scope is the request principal's — the gateway-minted org and its\noptional project — never anything off the wire, so a caller only ever sees its\nown. Rows carry no branches or HEAD; read one repo for those.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "get_v1_git_repos"
},
{
"description": "getRepo returns one repo with its live ref state: every branch name and the\nresolved HEAD commit. Both are read from the object store on each call, so an\nempty repo reports no branches and an empty head rather than failing. A repo\noutside the caller's scope is not found.",
"inputSchema": {
"properties": {
"name": {
"description": "Name is the repo's org-unique handle, from the :name path segment. A\ntrailing \".git\" is stripped.",
"type": "string"
}
},
"type": "object"
},
"name": "get_v1_git_repos_name"
},
{
"description": "browseBlob returns one file's bytes at one revision. Text comes back verbatim,\nbinary comes back base64, and a file past the 1 MiB view cap comes back marked\ntruncated with NO content — the client is expected to clone instead.",
"inputSchema": {
"properties": {
"name": {
"description": "Name is the repo to read, from the :name path segment.",
"type": "string"
},
"path": {
"description": "Path is repo-relative; empty is the tree root. Traversal is stripped.",
"type": "string"
},
"ref": {
"description": "Ref is a branch, tag or commit; empty means the repo's HEAD.",
"type": "string"
}
},
"type": "object"
},
"name": "get_v1_git_repos_name_blob"
},
{
"description": "browseCommits walks a ref's history newest first, or one path's history when a\npath is given. There is no cursor: the page is the newest `limit` commits.",
"inputSchema": {
"properties": {
"limit": {
"description": "Limit caps the page. Anything not positive means 50; the cap is 100.",
"type": "integer"
},
"name": {
"description": "Name is the repo to read, from the :name path segment.",
"type": "string"
},
"path": {
"description": "Path narrows the history to commits touching it; empty walks the whole ref.",
"type": "string"
},
"ref": {
"description": "Ref is the branch, tag or commit to walk back from; empty means HEAD.",
"type": "string"
}
},
"type": "object"
},
"name": "get_v1_git_repos_name_commits"
},
{
"description": "browseFiles returns every file a glob selects at one revision, WITH its bytes\nand the revision they came from. It is the read a delivery generator makes:\none call answers \"what is the inventory at this commit, and what does it say\",\nwhere listing and then fetching would be a request per file.\n\nReturning the resolved revision matters as much as the bytes. A generator that\nlists at `main` and then reads at `main` can straddle a push and assemble half\nits inventory from one commit and half from the next; resolving once makes the\nwhole read consistent by construction.\n\nA file past the read cap comes back Truncated with no content rather than\nbeing dropped. A caller building a desired set has to know the difference\nbetween \"this file is empty\" and \"this file was not read\" — silently omitting\nit is how a pruning reconcile deletes what the missing file declared.",
"inputSchema": {
"properties": {
"glob": {
"description": "Glob selects files, matched segment by segment so `*` never crosses a `/`.\n`**` matches zero or more whole segments.",
"type": "string"
},
"name": {
"description": "Name is the repo to read, from the :name path segment.",
"type": "string"
},
"ref": {
"description": "Ref is a branch, tag or commit; empty means the repo's HEAD.",
"type": "string"
}
},
"type": "object"
},
"name": "get_v1_git_repos_name_files"
},
{
"description": "listMirrors returns a repo's outbound mirror targets — the downstream remotes\nthe mirror reactor pushes to whenever a push lands here.",
"inputSchema": {
"properties": {
"name": {
"description": "Name is the repo's org-unique handle, from the :name path segment. A\ntrailing \".git\" is stripped.",
"type": "string"
}
},
"type": "object"
},
"name": "get_v1_git_repos_name_mirrors"
},
{
"description": "browseReadme returns the README at the tree root as plain text — unrendered, so\nthe caller decides how to present it. A repo with no README is not found.",
"inputSchema": {
"properties": {
"name": {
"description": "Name is the repo to read, from the :name path segment.",
"type": "string"
},
"ref": {
"description": "Ref is a branch, tag or commit; empty means the repo's HEAD.",
"type": "string"
}
},
"type": "object"
},
"name": "get_v1_git_repos_name_readme"
},
{
"description": "browseRefs lists a repo's branches, tags and default branch — what a branch\npicker needs in one call. Unlike the other read ops it tolerates a repo with no\ncommits: the ref sets come back empty and the default branch is still named.",
"inputSchema": {
"properties": {
"name": {
"description": "Name is the repo's org-unique handle, from the :name path segment. A\ntrailing \".git\" is stripped.",
"type": "string"
}
},
"type": "object"
},
"name": "get_v1_git_repos_name_refs"
},
{
"description": "listSubscriptions returns a repo's Slack subscriptions — which channels the\nlifecycle notifier posts this repo's push and deploy events to.",
"inputSchema": {
"properties": {
"name": {
"description": "Name is the repo's org-unique handle, from the :name path segment. A\ntrailing \".git\" is stripped.",
"type": "string"
}
},
"type": "object"
},
"name": "get_v1_git_repos_name_subscriptions"
},
{
"description": "browseTree lists the immediate children of one directory at one revision,\ndirectories before files. It does not recurse — walk down a level at a time.",
"inputSchema": {
"properties": {
"name": {
"description": "Name is the repo to read, from the :name path segment.",
"type": "string"
},
"path": {
"description": "Path is repo-relative; empty is the tree root. Traversal is stripped.",
"type": "string"
},
"ref": {
"description": "Ref is a branch, tag or commit; empty means the repo's HEAD.",
"type": "string"
}
},
"type": "object"
},
"name": "get_v1_git_repos_name_tree"
},
{
"description": "usage returns per-repo and total storage bytes for the caller's org — the\nqueryable, per-tenant number commerce and o11y meter on. It spans EVERY\nproject sub-scope, unlike the repo list, so a billing consumer sees the whole\ntenant footprint in one call. Sizes are last-measured values (create, push,\nmirror and gc each re-measure), not a live walk of the disk.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "get_v1_git_usage"
},
{
"description": "setVisibility flips a repo's public bit, the one mutable repo setting today.\nPublic grants ANONYMOUS fetch only; push and the whole control plane stay\norg-authed. Returns the updated repo.",
"inputSchema": {
"properties": {
"name": {
"description": "Name is the repo to update, from the :name path segment.",
"type": "string"
},
"public": {
"description": "Public flips anonymous read access. Omit it and the request is refused —\nthere is nothing else to update yet.",
"type": "boolean"
}
},
"type": "object"
},
"name": "patch_v1_git_repos_name"
},
{
"description": "registerKey registers an SSH public key so it can authenticate `git clone\ngit@\u003chost\u003e:\u003corg\u003e/\u003crepo\u003e.git` for the caller's org. The key line is parsed and\ncanonicalized before storage, its SHA256 fingerprint becomes the auth lookup\nhandle, and the full public key round-trips (it is public). Answers 201.\nFingerprints are globally unique, so a key already registered — to this org or\nany other — is a 409: one key belongs to exactly one org.",
"inputSchema": {
"properties": {
"publicKey": {
"description": "PublicKey is one OpenSSH authorized-key line (\"ssh-ed25519 AAAA… you@host\").\nRequired; a line that does not parse is refused and never stored.",
"type": "string"
},
"title": {
"description": "Title labels the key in the console. Max 256 chars; when omitted the\ncomment on the key line is used.",
"type": "string"
}
},
"type": "object"
},
"name": "post_v1_git_keys"
},
{
"description": "createRepo provisions an empty bare repository in the caller's scope and\nreturns it with its clone URLs. Answers 201. The name must be unique within\nthe scope — a repeat is a 409, never a silent overwrite of an existing repo.\nThe org comes from the validated principal, so a repo is always born owned by\nthe caller's own tenant.",
"inputSchema": {
"properties": {
"description": {
"description": "Description is a free-form blurb, max 4KiB.",
"type": "string"
},
"name": {
"description": "Name is the repo's handle, unique within the scope, and the last segment of\nboth clone URLs. Must match ^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$; a trailing\n\".git\" is stripped first. Required.",
"type": "string"
},
"project": {
"description": "Project narrows the repo to a sub-scope of the org. Omit it to use the\ncaller's own X-Project-Id scope; it can never widen past the caller's org.",
"type": "string"
},
"public": {
"description": "Public grants ANONYMOUS read (fetch) only; push and the whole control plane\nstay org-authed. Defaults to false.",
"type": "boolean"
}
},
"type": "object"
},
"name": "post_v1_git_repos"
},
{
"description": "gc repacks a repo into one bitmapped pack and rewrites its commit-graph, so\nthe next clone reuses the bitmap instead of walking the whole object graph.\nIdempotent, and safe to interrupt — git swaps both artifacts atomically. It\nruns under one pack slot with the same memory bounds as a clone, so it can\nblock behind heavy pack traffic rather than compete with it. Storage usage is\nre-measured afterwards, since a repack reclaims space.",
"inputSchema": {
"properties": {
"name": {
"description": "Name is the repo's org-unique handle, from the :name path segment. A\ntrailing \".git\" is stripped.",
"type": "string"
}
},
"type": "object"
},
"name": "post_v1_git_repos_name_gc"
},
{
"description": "mirror imports an external git repository into the caller's repo, provisioning\nit on first use. Fetch is FORCED and covers every ref, so a first call clones\nthe source and a repeat call re-syncs it — the endpoint is idempotent by mirror\nsemantics. Mirrored bytes are metered exactly like a push, and a push.landed\nevent is emitted for the default branch so the code index picks the repo up.",
"inputSchema": {
"properties": {
"name": {
"description": "Name is the local repo to mirror into, from the :name path segment. It is\nCREATED on first use.",
"type": "string"
},
"project": {
"description": "Project is the sub-scope to land the repo in; empty uses the caller's own,\nexactly as a create would.",
"type": "string"
},
"source": {
"description": "Source is the http(s) git URL to fetch from. The host is SSRF-guarded and\nthe shared mirror credential is only sent to allowlisted hosts.",
"type": "string"
}
},
"type": "object"
},
"name": "post_v1_git_repos_name_mirror"
},
{
"description": "addMirror registers a downstream remote the repo's advanced refs are pushed to\nwhenever a push lands here. Answers 201. The URL must be https to a host on the\nmirror allowlist (github.com / gitlab.com): the same set the mirror credential\nmay be sent to, so a target can never capture the shared token or point the push\nat an internal service. Any embedded userinfo is stripped — credentials ride\nenv-only at push time and never enter the stored URL. One mirror per host per\nrepo; a second is a 409.",
"inputSchema": {
"properties": {
"host": {
"description": "Host is an optional assertion of the target's hostname. The authoritative\nhost is the one in URL; a value that disagrees with it is refused.",
"type": "string"
},
"name": {
"description": "Name is the repo whose advanced refs are pushed downstream, from the :name\npath segment.",
"type": "string"
},
"url": {
"description": "URL is the downstream https git remote. Must be https to an allowlisted\nhost (github.com / gitlab.com); any embedded credentials are stripped.\nRequired.",
"type": "string"
}
},
"type": "object"
},
"name": "post_v1_git_repos_name_mirrors"
},
{
"description": "pushFiles lands a set of files as one commit without a git client — the\nhanzo.app builder's push. The repo is CREATED on first push, the files are\nmerged onto the branch tip (unlisted files survive), and the same\npush-to-deploy hook a real receive-pack fires is fired, so downstream this is\nindistinguishable from a `git push`.",
"inputSchema": {
"$defs": {
"pushFile": {
"properties": {
"content": {
"description": "Content is the file's bytes, carried per Encoding.",
"type": "string"
},
"encoding": {
"description": "Encoding is \"base64\", or \"utf-8\" (the default, also \"utf8\" / \"text\").",
"type": "string"
},
"path": {
"description": "Path is repo-relative. Absolute or traversing paths are refused.",
"type": "string"
}
},
"type": "object"
}
},
"properties": {
"branch": {
"description": "Branch to advance; empty means \"main\". A fresh branch that is the repo's\nfirst also becomes HEAD.",
"type": "string"
},
"files": {
"description": "Files are added to or overwritten on the branch tip — files already there\nand not listed SURVIVE. At least one, at most 5000, 32 MiB each.",
"items": {
"$ref": "#/$defs/pushFile"
},
"type": "array"
},
"message": {
"description": "Message is the commit message; empty gets a generated one.",
"type": "string"
},
"name": {
"description": "Name is the repo to push into, from the :name path segment. It is CREATED\non first push if it does not exist.",
"type": "string"
}
},
"type": "object"
},
"name": "post_v1_git_repos_name_push"
},
{
"description": "subscribe binds a Slack channel to a repo, so the lifecycle notifier posts\nthat repo's push and deploy events there. Answers 201. The same channel twice\non one repo is a 409; a repo outside the caller's scope is a 404, exactly as\nreading it is.",
"inputSchema": {
"properties": {
"channel": {
"description": "Channel is the Slack channel the notifier posts to — an id (C…/G…), a\n#name, or a bare name. Required.",
"type": "string"
},
"events": {
"description": "Events narrows delivery to these lifecycle kinds (push.landed,\ndeploy.live, deploy.failed). Omit it to receive every deliverable kind; a\nkind that is never posted to Slack is refused rather than silently dropped.",
"items": {
"type": "string"
},
"type": "array"
},
"name": {
"description": "Name is the repo to subscribe, from the :name path segment.",
"type": "string"
}
},
"type": "object"
},
"name": "post_v1_git_repos_name_subscriptions"
}
]
+18
View File
@@ -0,0 +1,18 @@
[
{
"description": "ListIndexers reports the deployment's chain indexer(s) and how far each has\nindexed. Identity and health come from the indexer's /health; the latest indexed\nblock (height + time) from its /v1/explorer/blocks. The row EXISTS if EITHER call\nreaches the indexer; when the indexer is entirely unreachable the answer degrades\nto an honest-EMPTY list at 200, not a 502. No chain HEAD is exposed by the indexer\nREST, so `lag` is honestly omitted rather than fabricated.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "get_v1_indexers"
},
{
"description": "ListOracles reports the on-chain price/data oracles from the graph's O-Chain\nPriceFeed registry. A reachable graph with no feeds answers an honest empty list;\nan unreachable or erroring graph likewise degrades to an empty list at 200 rather\nthan a 502, so the console never error-toasts. No feed is ever fabricated.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "get_v1_oracles"
}
]
+134
View File
@@ -0,0 +1,134 @@
[
{
"description": "DeleteCurriculum clears the caller org's curriculum override and returns the\njourney it falls back to — the brand blueprint, else the embedded fixture.\nClearing an org that never set one is a no-op that answers the same default.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "delete_v1_guide_curriculum"
},
{
"description": "Overview returns the caller org's launch journey: the active curriculum's\nversion and title, every step with its state, whether it is available, what\nblocks it and whether the Business AI can run it, the done/total/percent\nprogress with the next step to take, and the org's analytics funnel folded in.\nAuto-detect runs first, so a step the org has already completed elsewhere reads\ndone without anyone marking it.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "get_v1_guide"
},
{
"description": "ListActions returns the caller org's Business AI action ledger, most recent\nfirst: every \"do it for me\" tool call, the arguments it ran with, its result and\nwhether it succeeded. It is the audit-visible record of what the agent did on\nthe org's behalf, and the backing state for the \"acted\" auto-detect signal.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "get_v1_guide_actions"
},
{
"description": "Analytics returns the caller org's funnel from the analytics lens plus the GTM\nrecommendations derived from it. It is the Business AI's data-grounded read —\nwhat the funnel is doing, and the next-best action to move its weakest stage. An\nunreachable or silent warehouse answers available=false, never a fabricated\nnumber.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "get_v1_guide_analytics"
},
{
"description": "GetBlueprint returns the FULL authored brand blueprint — every principle,\nsection, step, strategy and template WITH its enabled flag made explicit,\nincluding the disabled items the org-facing reads never see — plus the active\nversion number, the brand key it is stored under and the item counts. It is the\nSuperAdmin authoring view of the platform blueprint, so it is refused 403 for\nanyone else, including a per-org admin: the brand blueprint is shared platform\ncontent, not a per-customer surface.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "get_v1_guide_blueprint"
},
{
"description": "ListBlueprintVersions returns the brand blueprint's version history — every\nstored version's number and edit time, newest first — which is the\npoint-in-time-recovery and audit trail behind the authoring plane. Metadata\nonly: the documents are not returned. SuperAdmin only, like the rest of this\nplane. The history is listable even when the current stored document no longer\nparses, so a schema-drifted row can still be diagnosed.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "get_v1_guide_blueprint_versions"
},
{
"description": "GetCurriculum returns the journey the caller's org is actually running, and\nwhether it comes from the org's OWN override (custom) or from the platform\ndefault — the brand blueprint, else the embedded fixture.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "get_v1_guide_curriculum"
},
{
"description": "Profile returns the caller org's OBSERVED growth profile — the signal set, the\nclassified growth stage, and the org's own key metrics. It is a pure READ,\nrecomputed from the org's CURRENT state each request (real-time by pull): it\nreuses the reconcile path (snapshotFor runs the detectors) for launch progress\nand runs the growth probes (observe) for the signals — it never caches, never\nruns a billable effect, never targets another org. Org-scoped on the validated\nprincipal; fail-closed without one. It PRODUCES the profile and classifies the\nstage; it decides NO recommendation (that is a later surface).",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "get_v1_guide_profile"
},
{
"description": "Strategies returns the ENABLED tactics corpus for the caller's org: the tactics\nlibrary narrowed by the explicit category/workload filters AND by the org's\nOBSERVED growth stage and capability signals (a tactic's tags are\npreconditions, so it surfaces only once the org can act on it). Passing stage\nPREVIEWS the corpus at that stage instead of the observed one. The content is\nshared platform data — no org's records — and the read is never a billable\neffect.",
"inputSchema": {
"properties": {
"category": {
"description": "Category filters to tactics in exactly this category.",
"type": "string"
},
"stage": {
"description": "Stage previews the corpus at a chosen growth stage\n(research|formed|launched|activated|scaling), overriding the org's observed\none. An unknown value is ignored and the observed stage stands.",
"type": "string"
},
"workload": {
"description": "Workload filters to tactics with exactly this workload.",
"type": "string"
}
},
"type": "object"
},
"name": "get_v1_guide_strategies"
},
{
"description": "Suggest returns the caller org's next-best quests: the available, non-terminal\nsteps of its journey ranked by how much downstream work each unblocks, each with\nthe grounded reason it is a good next move and whether the Business AI can run\nit, plus the org's funnel and the GTM recommendations derived from it. A\nbest-effort AI narrative over exactly those quests and numbers is included when\nan AI plane is wired. READ-ONLY: it advises and never runs a step — the only\nexecuting path is POST /v1/guide/steps/{id}/do.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "get_v1_guide_suggest"
},
{
"description": "Chat answers a founder's question about their launch journey as the Business AI\ncoach: it grounds the reply in the org's REAL progress, its ranked available\nquests and its analytics funnel, and returns those candidate quests alongside so\nthe caller can act on one. READ-ONLY — it advises and never runs a step, so it\ncannot be talked into performing an action; the only executing path is POST\n/v1/guide/steps/{id}/do. One AI completion per call, billed to the caller's own\npayer.",
"inputSchema": {
"properties": {
"message": {
"description": "Message is the founder's question for the Business AI. Required; trimmed,\nand clipped to 4 KiB so a caller cannot amplify the AI prompt.",
"type": "string"
}
},
"type": "object"
},
"name": "post_v1_guide_chat"
},
{
"description": "ResetStep returns one step of the caller org's journey to todo — clearing a\nmanual mark or a skip — and returns the refreshed journey. Reset is never\ndependency-gated. Auto-detect runs on the next read, so a step the org has in\nfact completed elsewhere goes straight back to done.",
"inputSchema": {
"properties": {
"id": {
"description": "ID is the step's id, as it appears in the journey (e.g. \"gsuite\").",
"type": "string"
}
},
"type": "object"
},
"name": "post_v1_guide_steps_id_reset"
},
{
"description": "SkipStep marks one step of the caller org's journey skipped and returns the\nrefreshed journey. Skipping is never dependency-gated — the founder is\ndeclaring the step does not apply to them — so a step whose dependencies are\nunfinished can still be skipped, and a skipped step counts as terminal for\neverything downstream of it.",
"inputSchema": {
"properties": {
"id": {
"description": "ID is the step's id, as it appears in the journey (e.g. \"gsuite\").",
"type": "string"
}
},
"type": "object"
},
"name": "post_v1_guide_steps_id_skip"
}
]
+65
View File
@@ -0,0 +1,65 @@
[
{
"description": "listArticles returns the public knowledge base: the help center's Published,\npublicly-visible articles as cards. The org is server-fixed and the\nstatus/is_public filter is server-set, so neither the tenant nor the visibility\ncan be widened by the caller. A deployment with no help center answers 404.",
"inputSchema": {
"properties": {
"category": {
"description": "Category narrows the list to one knowledge-base section, matched against\nthe article's category by exact name. Empty lists every section.",
"type": "string"
},
"limit": {
"description": "Limit caps how many articles are returned. Anything that is not a positive\ninteger uses 50, and values above 200 are clamped to 200.",
"type": "integer"
}
},
"type": "object"
},
"name": "get_v1_help_articles"
},
{
"description": "getArticle returns one public article by slug, with its body. A missing, Draft,\nor internal (non-public) article is 404 — fail-closed, so this route is no\nexistence oracle for anything beyond \"published and public\".",
"inputSchema": {
"properties": {
"slug": {
"description": "Slug is the article's public identifier, from the path. It IS the document\nname in the help center's store.",
"type": "string"
}
},
"type": "object"
},
"name": "get_v1_help_articles_slug"
},
{
"description": "listCategories returns the knowledge-base sections for the public center's\nnavigation — but ONLY the sections that front at least one Published, public\narticle, so an internal (agent-only) category name or description never leaks. A\nsection with no public article is invisible; a center with no public articles has\nno sections, which is an empty list rather than an error.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "get_v1_help_categories"
},
{
"description": "fileTicket files a customer support ticket into the public help center. It\ncreates the ticket (status Open, source portal) with the customer's message on\nthe description, then records that same message as the opening entry of the\nticket's conversation thread; the description carries it regardless, so failing\nto write that entry loses nothing. Answers 201 with an opaque reference.\n\nA deployment with no help center answers 404, one whose center has not installed\nthe Help model answers 503, and a body over 64 KiB answers 413 — in that order,\nwhich is the order the route has always decided them in.",
"inputSchema": {
"properties": {
"description": {
"description": "Description is the customer's message. Optional; it becomes the ticket's\ndescription AND the opening entry of its conversation thread. Clipped at\n16 KiB.",
"type": "string"
},
"email": {
"description": "Email is how the support team replies. Required; clipped at 320 characters\n(the RFC 5321 maximum). It is recorded as the ticket's customer, and it is\nnot verified.",
"type": "string"
},
"priority": {
"description": "Priority is Low, Medium, High or Urgent, case-insensitively. Anything else —\nincluding omitting it — is recorded as Medium rather than refused.",
"type": "string"
},
"subject": {
"description": "Subject is the one-line summary of the problem. Required; longer than 300\ncharacters is clipped rather than refused.",
"type": "string"
}
},
"type": "object"
},
"name": "post_v1_help_tickets"
}
]
+1
View File
@@ -0,0 +1 @@
[]

Some files were not shown because too many files have changed in this diff Show More