# 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"
}
```

## 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. Set `required: false` only when a connection mode can genuinely operate without a Credential.

`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.

For a user-supplied single endpoint, declare `configEgress` instead of granting a broad static origin. Installation compiles the selected URL to an exact Resource egress rule:

```ts
configEgress: [{
  urlProperty: "endpoint",
  operations: ["*"],
  methods: ["POST", "DELETE"]
}]
```

The referenced config property must be a URI string. User information, query parameters, and fragments are rejected so Credentials cannot leak into configuration or logs.

Packages that discover a remote catalog can declare a reviewed setup operation:

```ts
discovery: {
  operation: "mcp.tools.list",
  resultConfigProperty: "tools"
}
```

Declare the result property as a required, read-only array in `configSchema`:

```ts
configSchema: {
  type: "object",
  required: ["endpoint", "tools"],
  additionalProperties: false,
  properties: {
    endpoint: { type: "string", format: "uri" },
    tools: {
      type: "array",
      readOnly: true,
      items: { type: "object" }
    }
  }
}
```

Discovery has two strict configuration phases. Before schema and operation-catalog validation, setup supplies `tools: []`; after operator review, installation replaces that empty array with the signed snapshot. Consequently, `catalog(config)` must accept the empty array and still return the discovery operation. The discovery operation must be synchronous, read-only, and repeatable. Setup runs it with narrowly scoped Credential and egress capabilities. The Console returns the result for operator review, signs the discovery evidence, and installs the selected entries into `tools`. The package's pure `catalog(config)` function then turns that immutable snapshot into Resource operations. Runtime discovery must never mutate an installed catalog.

Gateway packages target the current public `GatewayDefinition` contract. The builder does not accept old transport response shapes or hosting metadata in an authored package.

`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.

## Import trained checkpoints {#import-trained-checkpoints}

A Gateway may also turn a provider checkpoint into an inference Model. This is a capability on the same Gateway Resource—not a separate deployment system. Declare both exact capabilities, the formats this immutable revision accepts, and a reconcilable `importCheckpoint` operation:

```ts checkpoint-gateway.ts
import {
  gateway,
  gatewayModelOffer,
  MODEL_CHECKPOINT_IMPORT_CAPABILITY,
  MODEL_COMPLETION_CAPABILITY,
  type CheckpointImportRequest,
} from "@constal/sdk";

export default gateway({
  id: "acme-models",
  version: "2.0.0",
  kind: "service",
  displayName: "Acme Models",
  description: "Serve base Models and imported Acme checkpoints.",
  credentialSlots: {
    account: { title: "Acme Credential", provider: "acme-api" },
  },
  capabilities: [
    MODEL_COMPLETION_CAPABILITY,
    MODEL_CHECKPOINT_IMPORT_CAPABILITY,
  ],
  checkpointImport: {
    lifecycleVersion: 1,
    formats: ["acme-peft"],
  },
  modelOffers: [],
  billing: { upstreamPayer: "tenant" },
  configSchema: { type: "object", additionalProperties: false },
  egress: { rules: [
    { operations: ["complete"], origin: "https://api.acme.example",
      pathPrefix: "/v1/completions", methods: ["POST"] },
    { operations: ["importCheckpoint"], origin: "https://api.acme.example",
      pathPrefix: "/v1/models/", methods: ["GET", "POST", "DELETE"] },
  ] },
  catalog: () => [
    { op: "complete", description: "Generate one model turn.",
      schema: {}, resultSchema: {}, effect: "read-only",
      recovery: { kind: "repeat" }, async: false,
      usage: { meter: "none", priceTable: null } },
    { op: "importCheckpoint", description: "Import a trained checkpoint.",
      schema: {}, resultSchema: {}, effect: "reconcilable",
      recovery: { kind: "reconcile" }, async: true,
      usage: { meter: "none", priceTable: null } },
  ],
  async invoke(request, ctx) {
    if (request.op === "importCheckpoint") {
      const input = request.args as CheckpointImportRequest;
      const remote = await startImport({
        descriptor: input.artifact.value,
        modelId: input.model.id,
      }, await ctx.secret("account"));
      await ctx.state.set(`import:${request.handleId}`, { remote, input });
      return { accepted: true };
    }
    return completeModel(request, ctx);
  },
  async reconcile(request, ctx) {
    const pending = await ctx.state.get<{
      remote: string;
      input: CheckpointImportRequest;
    }>(`import:${request.handleId}`);
    const imported = await readImport(pending!.remote, await ctx.secret("account"));
    if (!imported.ready) return { status: "in-progress", retryAfterMs: 5000 };

    return { status: "completed", result: gatewayModelOffer({
      id: imported.providerModel,
      displayName: pending!.input.model.displayName,
      contextTokens: imported.contextTokens,
      maxOutputTokens: imported.maxOutputTokens,
      inputModalities: ["text"],
      outputModalities: ["text"],
      pricing: imported.pricing,
    }) };
  },
  async cancel(request, ctx) {
    const pending = await ctx.state.get<{ remote: string }>(`import:${request.handleId}`);
    if (pending) await cancelImport(pending.remote, await ctx.secret("account"));
  },
});
```

`startImport`, `readImport`, `cancelImport`, and `completeModel` represent integration-specific code. The platform supplies a trusted request with source job, exact Training Provider CRN and hash, base model, checkpoint step, format, creation time, and content hash. `artifact.value` is the bounded provider descriptor saved by the Training Provider. The SDK verifies that its canonical content still matches `artifact.ref` before your adapter runs.

Import must be asynchronous, reconcilable, and cancellable. Return a standard `GatewayModelOffer` only after the provider can serve the imported model. Constal then creates an ordinary Model with immutable training provenance. The Gateway must not register the Model itself or change an Agent binding. Format matching is exact, and the Console lists only exact installed Gateway revisions that declare the selected checkpoint format.

## 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.
