# Build a custom Gateway

> Implement, package, test, and privately install model-provider connection code on the native Resource contract.

A Gateway package is immutable integration code plus setup metadata. Installation supplies tenant settings and Credential bindings, producing an ordinary connection Resource. Invocation still goes through Resource admission, exact version pinning, Policy, journaling, timeout enforcement, and scoped binding resolution.

## Project files {#project-files}

```text
constal.gateway.json
package.json
index.ts
test/gateway.test.ts
```

Keep the manifest at the archive root. Do not include `node_modules`, development dependencies, lifecycle scripts, symlinks, or secrets. Registry dependencies must use exact versions.

```json constal.gateway.json
{
  "schemaVersion": 2,
  "kind": "gateway",
  "id": "example-models",
  "namespace": "gateways",
  "version": "1.0.0",
  "entry": "index.ts",
  "expectedCurrentDeploymentRevision": null
}
```

## Define the package {#define-the-package}

Use `gateway(...)` to declare what operators see, what the installation collects, which Credentials it needs, the stable model-completion capability, and the exact external boundary.

```ts index.ts
import {
  ApiError, gateway, MODEL_COMPLETION_CAPABILITY,
  modelProtocolRequest, modelProtocolResult,
  type ModelInvocationArgs
} from "@constal/sdk";

const encode = (value: string) => {
  const bytes = new TextEncoder().encode(value);
  let binary = "";
  for (let at = 0; at < bytes.length; at += 32768) {
    binary += String.fromCharCode(...bytes.subarray(at, at + 32768));
  }
  return btoa(binary);
};
const decode = (value: string) => new TextDecoder().decode(
  Uint8Array.from(atob(value), (character) => character.charCodeAt(0))
);

export default gateway({
  id: "example-models",
  version: "1.0.0",
  kind: "service",
  displayName: "Example Models",
  description: "Run logical Models through an Example account.",
  documentationUrl: "https://example.com/docs/models",
  service: "Example",
  credentialSlots: {
    account: {
      title: "Example API Credential",
      description: "Choose a stored Credential containing an Example API key.",
      provider: "example-api"
    }
  },
  modelOffers: [{
    id: "example/reasoning-large",
    displayName: "Reasoning Large",
    contextTokens: 200000,
    maxOutputTokens: 8192,
    inputModalities: ["text"],
    outputModalities: ["text"],
    pricing: {
      currency: "USD", unit: "million_tokens", version: "2026-08-22",
      inputMicroUsd: 1000000, outputMicroUsd: 5000000,
      cachedInputMicroUsd: 100000, cacheWriteMicroUsd: 1250000
    }
  }],
  capabilities: [MODEL_COMPLETION_CAPABILITY],
  billing: { upstreamPayer: "tenant" },
  configSchema: { type: "object", additionalProperties: false },
  egress: { rules: [{
    operations: ["complete"], origin: "https://api.example.com",
    pathPrefix: "/v1/chat/completions", methods: ["POST"]
  }] },
  catalog: () => [{
    op: "complete", description: "Generate one model turn.",
    schema: {}, resultSchema: {}, effect: "read-only",
    recovery: { kind: "repeat" }, async: false,
    usage: { meter: "none", priceTable: null }
  }],
  async invoke(request, context) {
    if (request.op !== "complete") throw new TypeError("unknown operation");
    const args = request.args as ModelInvocationArgs;
    const model = String(args.model ?? "");
    const secret = await context.secret("account");
    const response = await context.egress({
      url: "https://api.example.com/v1/chat/completions",
      method: "POST",
      headers: {
        authorization: `Bearer ${secret.value}`,
        "content-type": "application/json"
      },
      bodyBase64: encode(JSON.stringify(
        modelProtocolRequest("openai", model, 4096, null, args)
      )),
      maximumResponseBytes: 4194304,
      maximumRedirects: 0
    });
    if (response.status < 200 || response.status >= 300) {
      throw new ApiError(response.status, decode(response.bodyBase64));
    }
    return modelProtocolResult(
      "openai", JSON.parse(decode(response.bodyBase64)), "example", model
    );
  }
});
```

`credentialSlots.account.provider` names the Credential Provider that produces compatible material. The Console uses it to filter the Credential picker and offer an inline **Connect** action. If that provider is a managed, zero-configuration package, the Gateway wizard can install it and start acquisition in one flow. The Gateway still receives only the final Credential binding; it never mints, refreshes, or stores secret material itself.

`configSchema` is only for non-secret installation settings and must reject unknown root fields. Secret-shaped fields are rejected during package validation; declare them in `credentialSlots` instead. The egress policy is an allowlist, not documentation. The integration cannot contact a different origin or path at runtime.

`modelOffers` is the optional immutable catalog shown by **Add Model**. Each offer declares the exact provider ID, technical limits, modalities, and an optional nominal USD-per-million-token pricing snapshot. Omit it when the available catalog is account-specific; operators can enter those Models manually. Offer pricing is display metadata, not billing authority. The Model's accepted accounting Policy alone determines tracked cost, Constal platform charge, and tenant customer charge.

## Preserve model semantics {#preserve-model-semantics}

The Agent-facing operation is `complete`. Read the admission-pinned model ID from `request.args.model`, translate the normalized prompt and Tools to the provider protocol, and return normalized content, Tool calls, token usage, cache usage, provider, and actual model. Report upstream cost only when the provider establishes it. Do not invent cost, retry non-idempotent uncertainty, hide `429`, or silently turn provider failures into empty model output.

## Test and install {#test-and-install}

Test request translation, Tool-call parsing, usage accounting, malformed responses, `429`, server errors, and streams that terminate before completion. Package the files as ZIP or TAR.GZ. In **Resources → Gateways → Add Gateway**, choose **Install a custom Gateway**, upload the archive or enter a pinned Git source, then complete the generated installation form. The resulting package remains private to the tenant unless the platform separately reviews and manages it.
