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
constal.gateway.json
package.json
index.ts
test/gateway.test.tsKeep the manifest at the archive root. Do not include node_modules, development dependencies, lifecycle scripts, symlinks, or secrets. Registry dependencies must use exact versions.
{
"schemaVersion": 2,
"kind": "gateway",
"id": "example-models",
"namespace": "gateways",
"version": "1.0.0",
"entry": "index.ts",
"expectedCurrentDeploymentRevision": null
}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.
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
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 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.