Hyperbolic Gateway

Hyperbolic Rest β€” design notes

Status: active development, branch feat/hb-rest. Slices 0–5 are implemented: the pure recipe library, wrapper store/executor, the hb-rest MCP server, the rotating bridge-token gate, encrypted host-bound credentials with a human-only Settings β†’ Wrappers editor, recipe pagination, documents mode, wrap_derive, opt-in Cortex indexing, generated wrap_<id> tools, and OAuth2 client-credentials with AUTH_REQUIRED handling and lastVerified. Read Honest status for what is partial, untested, and risky; the main open product decision is human approval for mutating calls.

Compatibility: the legacy kuang-wrap MCP label and KUANG_WRAP_* environment variables continue to work. New configurations should use hb-rest and HB_WRAP_*.

Hyperbolic Rest is a second MCP server that lets an agent turn a REST endpoint into a saved, typed tool: a wrapper for the request, a recipe for the response, and a secret reference for the credential. It is the other half of the browser: HTML for humans, JSON for agents.

Shape

Agent ──stdio──▢ hb-rest ──HTTP──▢ localhost bridge ──▢ WrapService (main)
                    β”‚                                      β”œβ”€ wrapper store
                    β”‚                                      β”œβ”€ secret store (safeStorage)
                    └─ tools/list + list_changed            β”œβ”€ HTTP executor
                                                           └─ recipe β†’ Markdown
Layer Owns Never owns
packages/recipe Pure JSON β†’ Markdown translation Network, files, secrets, Electron
src/main/wrap Wrappers, secrets, execution, audit MCP protocol
src/mcp/wrap-server.ts MCP tool surface, dynamic tool list Credentials, parsing rules
Bridge Local routing + auth token Long-lived state

The MCP process is stateless. It asks the app for the manifest and forwards calls. Secrets never enter the MCP process. That is the whole security model in one sentence.

Honest status: what works, what doesn't, what is untested, what is risky

This section is deliberately blunt. The wrapper system is a developer tool, not a hardened API gateway; read it before pointing it at anything that matters.

What it does and what it is for

Hyperbolic Rest turns a REST endpoint into a saved, typed tool. A wrapper is a deterministic request template plus a response recipe; a credential is a host-bound secret that main attaches at request time. An agent gets flat, validated arguments instead of assembling URLs, headers, and JSON bodies, and it never sees a credential value. It is for agent access to JSON APIs β€” dashboards, internal tools, SaaS APIs, documentation β€” not for general web scraping (that is the browser) and not for streaming or binary workloads.

What works

Area State
Request build URL/query/header/body templates, typed JSON (numbers/booleans/arrays/objects preserved), omitted vs explicit null, defaults, no expression language
Argument validation JSON Schema subset in main; generated tools get the same schema as Zod for the client
Outbound policy HTTPS only (plain HTTP opt-in), private/loopback/link-local blocked, redirect cap + re-check, credential dropped on a cross-origin redirect, 5 MB cap, 15 s default timeout
Credentials Static value, or OAuth2 client_credentials (mint, cache, early refresh, one forced re-mint on 401); encrypted with safeStorage; human-only writes; host-bound; values never enter the MCP process
Recipes Table / document / documents; fields, record projection, templates; escaping, item and character caps with meta.truncated
Pagination Offset, cursor, and link; page cap (10, max 25) and item cap; merged into one render; one audit row per page; link pages must stay same-origin
Derivation wrap_derive from a JSON sample or a policy-checked URL
Cortex indexing Opt-in per recipe (index: "cortex"); best-effort; documents mode indexes per document, otherwise one page per result
Generated tools Opt-in per wrapper (exposeAsTool), typed args, capped by HB_WRAP_MAX_TOOLS (default 40; 0 disables), tools/list_changed on change
Verification metadata lastVerified written after a successful live run and shown in wrap_list
Audit audit.jsonl: method, host, status, bytes, duration, error code β€” no headers, bodies, or query strings
Tests 50 offline, 11 generated-tool lifecycle (real MCP client), 46 live against DummyJSON

What does not work (or is partial)

What is untested (or only tested offline)

What is risky

Non-goals for v1

Agent-facing surface

The wrapper is a projection of the API, not a mirror of it. The agent should never assemble a URL, a header set, or a body JSON unless the wrapper author explicitly exposes a raw body. The wrapper author β€” human or agent β€” does that work once; every call is a short, typed argument list.

  1. Semantic inputs, flat arguments. customer_id, items, note; not { data: { attributes: { … } } }. The MCP inputSchema carries the types, descriptions, enums, defaults, and examples.
  2. One action, one tool. wrap_orders_create beats a generic wrap_request(url, method, body) for discoverability and token cost. A wrapper with exposeAsTool: true becomes exactly that (wrap_<id>); otherwise the stable wrap_run { id, args } meta-tool is the call path.
  3. Deterministic mapping. request.body is a template. The same arguments always produce the same request. There is no model-authored JSON.
  4. Preview before mutation. A dry run returns the exact URL, headers (redacted), and body without sending it.
  5. Dual output. Markdown for reading, records for querying. The agent asks for format: "json" instead of parsing the Markdown.
  6. Token budget is part of the contract. Only declared fields, caps on items and characters, and a compact wrap_list.
  7. Errors are actionable. Status, code, field, retryable, and a short hint; never a full HTML error page.

POST, the simple path

A create wrapper:

{
  "schemaVersion": 1,
  "id": "orders.create",
  "name": "Create order",
  "request": {
    "method": "POST",
    "url": "https://api.example.com/v1/orders",
    "body": {
      "customer": "{{customer_id}}",
      "line_items": "{{items}}",
      "note": "{{note}}"
    }
  },
  "inputSchema": {
    "type": "object",
    "properties": {
      "customer_id": { "type": "string", "description": "Customer ID" },
      "items": {
        "type": "array",
        "description": "Line items",
        "items": {
          "type": "object",
          "properties": {
            "sku": { "type": "string" },
            "qty": { "type": "integer", "minimum": 1 }
          },
          "required": ["sku", "qty"]
        }
      },
      "note": { "type": "string", "description": "Optional note" }
    },
    "required": ["customer_id", "items"],
    "additionalProperties": false
  },
  "mutating": true,
  "response": {
    "schemaVersion": 1,
    "select": "$",
    "mode": "document",
    "record": { "id": "id", "status": "status", "total": "total" },
    "fields": {
      "Order": "id",
      "Status": "status",
      "Total": { "path": "total", "format": "number" }
    }
  }
}

The agent sees a tool with three arguments and calls it:

wrap_orders_create({
  "customer_id": "cus_123",
  "items": [{ "sku": "A-1", "qty": 2 }],
  "note": "gift"
})

The app sends exactly:

POST /v1/orders
Content-Type: application/json
Authorization: Bearer <from the host-bound secret store>

{"customer":"cus_123","line_items":[{"sku":"A-1","qty":2}],"note":"gift"}

The agent never saw the URL, the authorization value, or the body envelope. The wrapper references "auth": "orders-key" by name; main resolved it only because the request host matched that secret's allowed hosts.

The agent saw a create-order action with a customer, line items, and an optional note.

Interpolation rules

Template Input Result
"{{items}}" (whole string) array/object/number/boolean inserted with its JSON type
"Order for {{customer_id}}" (embedded) string text interpolation
"{{note}}" omitted/undefined key omitted from the body
"{{note}}" explicit null null, if the schema allows it
"{{q}}" in query string URL-encoded
"{{tag}}" in query array repeated tag=a&tag=b
"{{value}}" in headers string value only; header names are fixed
"{{path|raw}}" in url string slash-separated path: each segment encoded, / preserved; ., .., empty segments and backslashes rejected

No expressions, no eval, no model-generated JSON. If an API truly needs a free-form body (for example an Elasticsearch query DSL), the wrapper opts in with body: "{{body}}" and an input of type: "object"; that is explicit, visible in the schema, and still previewable.

The agent loop

wrap_list                     # what can I do? one line per wrapper
wrap_get { id }               # full input schema, request template, recipe
wrap_secret_list              # credential names + allowed hosts; never values
wrap_test { id, args }        # dry-run the exact request (default)
wrap_run { id, args }         # execute; add format: "json" for records
  └─ mutating? wrap_test first
result.markdown               # what to read
result.records                # what to filter, store, or return as JSON

wrap_list returns only id, a one-line description, method + host, and the required argument names. wrap_get returns the full schema when the agent needs it. That keeps discovery cheap.

Trying it

npm run build:mcp        # writes bin/mcp-server.cjs and bin/wrap-server.cjs
npm run test:wrap        # 50 offline store/executor/secret/pagination/indexing/schema/oauth2 tests
npm run test:wrap:mcp    # 15 generated-tool lifecycle + cap checks over a real MCP client
npm run test:wrap:live   # 46 live checks against DummyJSON (needs the app; inline wrappers, nothing saved)

Open Settings β†’ MCP Clients, choose REST wrappers (hb-rest) as the server, then copy the snippet into the agent client. The snippet includes HB_WRAP_TOKEN_FILE, which the server reads on each call. The Cyberdeck must be running; the wrap server only forwards to the existing local bridge.

Open Settings β†’ Wrappers to browse and edit saved wrappers and to add credentials. A credential is write-only; editing it with a blank value keeps the current ciphertext and only changes hosts/header metadata.

A first loop with a public API:

wrap_create { wrapper: { id, name, request, inputSchema, response } }
wrap_test   { id, args }        # dry run: exact method, URL, headers, body
wrap_run    { id, args }        # live; add format: "json" for records

For an authenticated API, a human adds a secret in Settings β†’ Wrappers and binds it to the API host. The wrapper uses "auth": "<name>"; agents can call wrap_secret_list to discover names, but never see values.

Wrapper spec

A wrapper is a small JSON document. request is executed; response is a recipe; inputSchema is the MCP input schema.

{
  "schemaVersion": 1,
  "id": "weather.current",
  "name": "Current weather",
  "description": "Current conditions for a city.",
  "request": {
    "method": "GET",
    "methods": ["GET", "POST"],
    "url": "https://api.example.com/v1/weather",
    "query": { "q": "{{city}}" },
    "headers": { "Accept": "application/json" },
    "auth": "weather-key",
    "timeoutMs": 15000
  },
  "inputSchema": {
    "type": "object",
    "properties": { "city": { "type": "string" } },
    "required": ["city"]
  },
  "response": {
    "schemaVersion": 1,
    "select": "$.current",
    "mode": "document",
    "record": {
      "city": "location.name",
      "temp_c": "temp_c",
      "updated": "last_updated"
    },
    "fields": {
      "City": "location.name",
      "Temperature": { "path": "temp_c", "format": "number", "suffix": " Β°C" },
      "Updated": { "path": "last_updated", "format": "date" }
    }
  },
  "exposeAsTool": false,
  "mutating": false
}

Pagination

A recipe may declare pagination; the executor then follows pages and merges them into one render, so an agent gets one table instead of N tables with repeated headings.

"pagination": {
  "type": "offset",
  "limitParam": "limit",
  "offsetParam": "skip",
  "pageSize": 10,
  "hasMore": "total"
}
type Next page comes from Stops when
offset offsetParam + limitParam, advanced by pageSize a page is shorter than pageSize, hasMore is false/0, or a cap is hit
cursor next (path to the next token), sent as cursorParam next is missing/null/empty
link nextUrl (path to the next absolute URL) nextUrl is missing, or points off-origin
none β€” single request (default)

Rules:

Recipes

The translation layer is packages/recipe, a pure ESM package with no dependencies. It exports deriveRecipe, renderRecipe, resolvePath, renderTemplate, validateRecipe, and the format types. The JSON Schema is recipe.schema.json.

It deliberately has no eval, no network and no clock. That makes it safe to run on untrusted API responses, easy to test with Node's built-in runner, and publishable on its own later.

renderRecipe returns markdown, records, documents, and meta. records is the queryable projection (recipe.record names the keys) with original JSON types. In documents mode each entry is { title, markdown, record }. There is no Markdown β†’ JSON parser: the executor can return the records directly with format: "json", which is lossless and cheaper.

Packaging decision: monorepo now, standalone repository/package when the format has survived a few real APIs unchanged or a second consumer appears. The package boundary is enforced by imports: nothing in packages/recipe may import Electron or MCP.

Deriving a recipe

wrap_derive infers a first-draft recipe from a real response, so you never start from a blank page:

wrap_derive { sample }                                   # object, array, or JSON string
wrap_derive { url }                                      # policy-checked GET, then derive
wrap_derive { sample, max_fields: 6, id, name, description }

It picks the first array it recognises (items, results, data, records, …, then any top-level array), takes up to max_fields primitive fields from the first item, and chooses table for arrays or document for objects. The URL form runs through the same outbound policy as any wrapper (https, no private hosts) and never attaches a secret.

The result is a recipe, not a wrapper: pass it as response to wrap_create (or wrap_update, then wrap_reload). Edit it afterwards β€” derivation is deliberately shallow.

Cortex indexing (opt-in)

A recipe can ask the executor to store its rendered output in the local Cortex, so recall_memory finds it later:

"response": {
  "schemaVersion": 1,
  "mode": "documents",
  "select": "$.items",
  "title": "{{title}}",
  "template": "{{body}}",
  "index": "cortex"
}

Secrets

Secrets are implemented in main. The Settings β†’ Wrappers panel is the only writer; the MCP process and the bridge see names and metadata only.

Rule Why
Secrets are written only from Settings over IPC An agent must not be able to create, read, or bind a credential
safeStorage encrypts at rest in main DPAPI / Keychain / libsecret, no custom crypto
Agents see names and allowed hosts, never values wrap_secret_list returns metadata only
A secret is bound to allowedHosts A wrapper cannot attach it to an attacker's host
Binding a secret to a host is human-only Prevents agent-authored exfiltration
No auth on cross-origin redirects The injected header is dropped when the origin changes
No credentials in query strings Query strings end up in logs and proxies
Upstream responses are scrubbed for the injected value A debug/echo endpoint must not reflect the token back to the agent
Audit log records method, host, status, bytes Debugging without storing headers, bodies, or query strings

A secret is write-only in the UI. Editing it with a blank value preserves the ciphertext and lets a human change only the allowed hosts, header, or prefix. The bridge's /wrap/secrets route is read-only metadata; there is no HTTP or MCP route that sets a value.

Prerequisite (done): every /wrap/* route requires a rotating bearer token. The token is generated on each app start, written to <userData>/wrap/token with owner-only permissions, and the MCP config carries HB_WRAP_TOKEN_FILE (the path), not the value. The wrap server reads the file per call, so a long-lived MCP process survives an app restart. Without a valid token the bridge returns 401 and never touches a wrapper.

On Linux, if safeStorage.getSelectedStorageBackend() returns basic_text, secrets are disabled and the panel explains how to fix the keyring. The app does not pretend that basic_text is encryption.

OAuth2 client credentials

A credential is either a static value (paste the token/key) or an OAuth2 client_credentials client:

Field Meaning
tokenUrl HTTPS token endpoint. Policy-checked (no private hosts unless allowed) but not restricted by allowedHosts
clientId sent as client_id
value (write-only) the client secret, encrypted at rest
scope / audience optional, sent only when present

Minting is a form-encoded POST (grant_type=client_credentials&client_id=…&client_secret=…). The access token is cached (encrypted, alongside the record) until 60 s before expires_in, then re-minted. A wrapper call that gets 401/403 triggers one forced re-mint and a single retry; if that still fails the result is AUTH_REQUIRED with a hint to update the credential or raise HIL. There is no retry loop and no background refresher.

Not supported: authorization-code, device flow, PKCE, refresh tokens, per-user tokens, token revocation, OIDC discovery. The honest-status section lists the associated risks.

Verification metadata

Every successful live run writes lastVerified (ISO timestamp) into the wrapper file. wrap_list shows verified YYYY-MM-DD, or never verified. It means "the upstream answered without an HTTP error" β€” a liveness signal only, not proof that the output was correct.

Execution policy

MCP surface

Meta-tools (always present):

Tool Purpose
wrap_list List wrappers: id, name, method, host, required arguments, secret name
wrap_get Read one wrapper definition (no secret values)
wrap_derive Infer a first-draft recipe from a JSON sample or a live URL
wrap_secret_list Credential names, headers, and allowed hosts only
wrap_create Validate and save a wrapper
wrap_update Replace a saved wrapper
wrap_delete Remove a wrapper
wrap_run Execute by id with flat arguments; Markdown, records, or both
wrap_test Dry-run, or render a sample response without sending
wrap_reload Re-read the manifest after an external edit

A wrapper with exposeAsTool: true is also registered as its own tool, named wrap_<id> with every character outside [A-Za-z0-9_-] replaced by _ (dummyjson.auth.me β†’ wrap_dummyjson_auth_me), taking the wrapper's declared arguments as a typed Zod schema (converted from its JSON Schema). Generated tools are capped by HB_WRAP_MAX_TOOLS (default 40; 0 disables); the rest stay callable through wrap_run. Create/update/delete and wrap_reload reconcile the set, and the SDK emits tools/list_changed, so a connected client sees the change without a restart. They belong to hb-rest only β€” the browser server's tool list is untouched.

Results are Markdown text; format: "json" returns the record array. structuredContent and document passthrough are planned.

Bridge routes

All under /wrap on the existing 127.0.0.1 bridge. Every route requires the rotating bridge token described below. Secret writes are IPC-only; the bridge exposes read-only metadata.

Route Body Result
GET /wrap/manifest β€” { wrappers: [{ id, name, method, host, required, secret, … }] }
GET /wrap/status β€” wrapper count and whether private hosts are allowed
GET /wrap/wrapper?id=… β€” one full wrapper
GET /wrap/secrets β€” names, headers, allowed hosts, availability; never values
POST /wrap/run { id, args, format? } { ok, status, markdown, records, request, … }
POST /wrap/test { wrapper?, id?, args?, sample?, dryRun?, maxPages? } preview or sample render; nothing saved
POST /wrap/derive { sample?, url?, id?, name?, description?, max_fields? } { source, recipe }; the URL form is a policy-checked GET
POST /wrap/wrappers { action: "create"|"update"|"delete"|"get", wrapper? , id? } saved wrapper or error list
GET /wrap/audit?limit=50 β€” redacted audit rows

Bridge token

Storage

<userData>/wrap/
  wrappers/<id>.json      one file per wrapper, atomic write (tmp + rename)
  secrets.json            metadata + safeStorage ciphertext, main process only
  audit.jsonl             append-only, redacted, one row per outbound call
  token                   rotating bridge token, owner-only permissions

Every wrapper carries schemaVersion. The validator normalises to the known fields; unknown top-level fields are dropped, so add a field to the spec before relying on it. No credential values ever appear in wrappers/; secrets.json contains ciphertext only. Recipes are embedded in the wrapper for now; a separate reusable recipe store is planned.

Packaging

Slices

Slice Scope Gate
S0 βœ… Pure recipe package, schema, tests, design 25 tests pass, typecheck clean
S1 βœ… Wrapper store, executor, /wrap/*, hb-rest MCP server, create/update/delete, dry-run and sample tests 21 core tests now pass; a live GitHub API call returned Markdown and records
S2a βœ… Rotating wrap bridge token, token-file handoff, 401 gate Missing/stale token gets 401 before any wrapper or outbound request
S2 βœ… safeStorage, host binding, Settings β†’ Wrappers editor, audit Unit tests prove a foreign-host secret is refused and a cross-origin redirect drops the injected header
S3 βœ… Derived recipes (wrap_derive), documents mode, pagination (offset/cursor/link, page- and item-capped), and opt-in Cortex indexing A paginated API returns all pages up to the cap βœ…; documents are searchable βœ…
S4 βœ… Generated tools, sendToolListChanged; MCP Clients entry and docs are already in S1 cap configurable (HB_WRAP_MAX_TOOLS) and visible βœ…; client sees a new tool without restart βœ…
S5 βœ… OAuth2 client credentials, expiry handling, lastVerified One re-mint, then AUTH_REQUIRED with a human-action hint βœ… (no silent 401 loop); lastVerified written on success βœ…

Threat model

Threat Control
Agent creates a wrapper pointing at an attacker host and attaches a secret Host-bound secrets; human-only binding; no cross-origin auth redirect
Local process calls the bridge and spends tokens Rotating token, read from a user-private file; every /wrap/* route returns 401 without it.
SSRF into localhost or cloud metadata Scheme + DNS/IP checks; private hosts require HB_WRAP_ALLOW_PRIVATE=1. DNS rebinding remains a residual risk until the checked address is pinned in the socket.
A paginated next link points at an attacker host link pagination is same-origin only; every page URL is re-checked by assertSafeUrl before it is fetched
Credential leaks into logs or responses Bridge logs and previews redact credential-shaped keys; an upstream response that echoes a secret is redacted before rendering; audit rows store host/status/bytes only; tests assert values are absent.
Prompt injection inside an API response Response is data; it is rendered, never executed
Tool-list exhaustion Ten meta-tools plus opt-in generated tools, capped by HB_WRAP_MAX_TOOLS (default 40)
Schema drift breaks a wrapper wrap_test and clear upstream errors; lastVerified is stored but not refreshed yet
Linux has no keyring Detect basic_text; warn and refuse secrets if requested

Open questions

  1. Should a wrapper be runnable headless (without the Electron app)? Not in v1. The separate MCP binary keeps that door open.
  2. Secret scope: host-only is shipped. Add an optional wrapper scope if a shared host needs separate credentials.
  3. Should POST require human approval by default? Leaning yes for the first call per wrapper per session; read-only remains frictionless.
  4. Where do parsed documents go? Shipped: returned inline and, when a recipe sets index: "cortex", also written to Cortex β€” opt-in because it costs embeddings and disk.
  5. Do we expose generated tools by default? Shipped: no β€” opt-in per wrapper (exposeAsTool), capped by HB_WRAP_MAX_TOOLS, with tools/list_changed on change.

Decision log