Smarter’s OpenAPI Contract: One Extensibility Model for Python, Go, React and VS Code

Smarter publishes its whole REST API as one OpenAPI 3.1 document, openapi.json, plus a JSON Schema for every manifest kind. That OpenAPI document is Smarter’s extensibility model. It’s generated from the platform’s own Pydantic models, committed to the repository, tested against the platform’s real responses, and it’s the only thing that Smarter’s clients depend on. This post explains why OpenAPI is the right choice for a platform that has to be extended from many technologies, how Smarter generates and tests the document, and how four clients use it, namely smarter-python, smarter-cli, Smarter Chat and smarter-vscode-yaml.

The problem it solves is where to put the seam, which is the line between what a platform owns and what everyone else is allowed to depend on. Put the seam in the wrong place and every client becomes a hostage to your internals. Rename a model, split a table, refactor a class, and somewhere a Go binary, a WordPress page and a data scientist’s notebook break at once, and you find out from them.

Smarter is a Django platform for building and running LLM applications, and its seam has to hold for an unusually mixed crowd. A Python developer scripts it from a notebook. A platform engineer drives it from a Go CLI in a CI pipeline. A marketing team embeds a chat widget on a WordPress, Drupal or Shopify site. A prompt engineer writes YAML manifests in VS Code and expects autocomplete. None of these people write Python inside the platform, and none of them should have to. Everything on one side of the OpenAPI document is Smarter’s business. Everything on the other side is the client’s.

Three-column diagram of Smarter's seam. On the left, Smarter's business behind the seam, with a broker per kind and an abstract broker class, Pydantic SAM models, a Django model per resource, ORM mapping, caching and dependency tracking, multi-tenant accounts, Kubernetes deployments and the journal of every request. It generates the contract files in the middle, which are openapi.json (OpenAPI 3.1, the whole /api/v1), sam/<Kind>.schema.json, sam/<Kind>.example.json and chat-contract.schema.json, all generated, committed and tested. On the right, the four clients that read them, smarter-python (Python), smarter-cli (Go), Smarter Chat (TypeScript, React) and smarter-vscode-yaml (TypeScript, VS Code). Clients send YAML to a URL and get back JSON in a documented envelope, and nothing else crosses.

Four clients, four technologies, one document

ClientLanguageWhat it reads from the contractWhat it does with it
smarter-pythonPythonopenapi.json, every sam/<Kind>.schema.jsonGenerates a Pydantic model per manifest kind; tests every SDK request against the document’s operations
smarter-cliGoopenapi.jsonTests that every command maps to a documented operation and every kind it sends is a documented kind
Smarter ChatTypeScript, ReactThe chat contract’s JSON Schema, from the same models as the document’s chat apiGenerates its TypeScript types; validates its test fixtures against the schema
smarter-vscode-yamlTypeScript, VS Codeopenapi.json, and each kind’s schema from the live json-schema endpointGenerates its API client’s types; validates, autocompletes and documents manifests as you type

Four languages and runtimes, three code generators, and not one line of shared code between any client and the platform. That’s the shape a resilient extensibility model should have.

What’s behind the seam

To appreciate what the clients are spared, it helps to see a little of what they don’t have to know.

Smarter’s resources (LLMClients, Plugins, Guardrails, Secrets, MCPClients and so on) are declared in YAML manifests, in a format called SAM, the Smarter Application Manifest. Each kind has a broker, a class that validates a manifest with its Pydantic model, translates it into Django ORM rows, enforces ownership and permissions, and carries out the verbs apply, get, describe, delete, deploy and logs. The design follows kubectl deliberately, as ADR-024 records. The registry of brokers is a dictionary from a kind’s name to its class, from smarter/apps/api/v1/cli/brokers.py.

_brokers: Dict[str, Type[AbstractBroker]] = {
    SAMKinds.ACCOUNT.value: SAMAccountBroker,
    SAMKinds.API_CONNECTION.value: SAMApiConnectionBroker,
    SAMKinds.API_PLUGIN.value: SAMApiPluginBroker,
    ...
    SAMKinds.GUARDRAIL.value: SAMGuardrailBroker,
    SAMKinds.LLM_CLIENT.value: SAMLLMClientBroker,
    ...
}

And the view behind POST /api/v1/cli/apply/ does little more than find the broker and hand it the request, from smarter/apps/api/v1/cli/views/apply.py.

response = self.broker.apply(
    request, user=user, account=account, user_profile=user_profile, args=args, kwargs=kwargs
)

Behind that one call sit an abstract broker class with its own loader, ORM mapping, caching, dependency tracking and error classes; a Django model per resource; multi-tenant accounts; Kubernetes deployments for some kinds; and a journal that records every request and response. That’s a lot of machinery, and it changes often. A client that imported any of it would change with it.

None of it crosses the seam. A client sends YAML to a URL, and gets back JSON in a documented envelope. Whether apply creates three ORM rows or thirty, whether a broker is refactored, whether the Django models are split or merged, is invisible from the other side, as long as the document doesn’t change. And if the document does change, everybody finds out from a diff, not from a bug report.

Why OpenAPI, and not something else?

There are four reasonable places to put the seam of an extensibility model. Each solves the problem for some technologies and fails it for others.

Share code

Publish the platform’s models as a library, and let clients import them. It’s the cheapest choice on day one, and it ties every client to one language and to the platform’s internals. Smarter’s SAM models are Pydantic models that depend on Django. The Python SDK’s model generator says so plainly, in scripts/generate_sam_models.py.

The platform's SAM (Smarter Application Manifest) models are Pydantic models that depend on Django,
so the SDK cannot import them. The platform commits each kind's JSON Schema, generated from those
models, and an example manifest of each kind, in ``smarter/smarter/apps/api/v1/contract/sam/``.

A Go CLI or a browser can’t import them at all.

Hand-write a client per language

This works, until the third client. Then the platform has three descriptions of its API, all written from memory, and nothing checks any of them. Hand-written documentation fails the same way, because it describes what someone remembered, not what the code does.

A bespoke interface language

gRPC and Protocol Buffers are excellent between services. They’re a poor fit for a YAML file that a person writes in VS Code, a fetch() from a WordPress page, or a curl in a shell script. The people who extend Smarter speak HTTP and JSON already.

A standard, machine-readable description of HTTP and JSON

That’s OpenAPI. Its strength isn’t elegance. It’s ubiquity. Practically every language has a validator, a type generator, a mock server and a documentation renderer for it, and most developers have seen one. Smarter’s clients use four different off-the-shelf tools against the same files, and none of them was written for Smarter.

And every Smarter platform serves its own document at /docs/openapi.json, rendered with Scalar at /docs/openapi/, so a developer evaluating Smarter can read the whole API, and try it, without opening the repository.

There’s one more reason that’s specific to Smarter, and it’s a happy one. OpenAPI 3.1’s schemas are exactly JSON Schema 2020-12, which is what Pydantic emits. The platform already validates every manifest with Pydantic, so the contract isn’t a second description of the API. It’s the first description, exported.

The seam goes where the most technologies can reach it, and OpenAPI is that place.

How the contract is built

The contract lives in smarter/apps/api/v1/contract/. It holds three kinds of file.

  • openapi.json, the OpenAPI 3.1 document of the whole API under /api/v1, covering the CLI api, the chat api, and the REST reads of each resource family.
  • sam/<Kind>.schema.json, one JSON Schema per manifest kind, exactly what POST /api/v1/cli/json-schema/<Kind>/ returns.
  • sam/<Kind>.example.json, one example manifest per kind.

All of them are generated, never edited by hand, by make api-contract, which runs a management command inside the platform’s container. They’re committed, so a change to the API is a change to a file that a reviewer sees in the pull request.

Generated from the models that build the responses

The rule that makes this work is ADR-031. Any sizable JSON that the platform builds, returns or reads is a Pydantic model, and the view builds its response from the model, rather than building a dict and documenting it somewhere else. Rule 6 of that ADR reads as follows.

JSON that leaves the repository gets a published schema. When a client in another language reads the data, for example a React app or an npm package, the repository commits the models’ JSON Schema, the client generates its types from it, and a test fails when the committed schema is out of date.

So the document and the response come from the same object. They can’t disagree, because there’s only one of them.

A few decisions that make the document useful to generators

The document is assembled in openapi.py, and a handful of its choices exist purely so that off-the-shelf tools produce good clients.

Every response shares one envelope, the Smarter Journal‘s, with the command’s own data beside it. One helper composes it.

def envelope(data: Optional[OpenApiSchema], description: str) -> OpenApiSchema:
    ...
    return {
        "description": description,
        "allOf": [
            ref(SAMJournalApiEnvelope.__name__),
            {"type": "object", "properties": properties, "required": required},
        ],
    }

A manifest of any kind is a oneOf over every kind’s schema, with a discriminator on kind. The SAM models declare kind as a plain string, which would match every branch, so the document pins each one to a constant.

for kind, name in kind_schemas.items():
    schemas[name]["properties"]["kind"]["const"] = kind
schemas["SAMManifest"] = {
    "description": "A SAM manifest of any kind. Its kind selects its schema.",
    "oneOf": [ref(name) for name in kind_schemas.values()],
    "discriminator": {
        "propertyName": "kind",
        "mapping": {kind: REF_TEMPLATE.format(model=name) for kind, name in kind_schemas.items()},
    },
}

A generated TypeScript client then types kind as a literal, and narrows a manifest’s spec by its kind, for free.

Diagram of the SAMManifest schema. A manifest with apiVersion smarter.sh/v1 and kind Guardrail is matched against SAMManifest, a oneOf over every kind's schema with a discriminator on the kind property. Its branches include SAMGuardrail with kind const Guardrail, SAMLLMClient with kind const LLMClient, SAMMCPClient with kind const MCPClient and SAMStaticPlugin with kind const Plugin, one branch per kind. Because the document pins each schema's kind to a constant, a generated TypeScript client types kind as a literal and narrows a manifest's spec by its kind.

info.version is the API’s version, v1, not the platform’s, so the committed file doesn’t go stale on every release. And a prompt’s streamed response, which OpenAPI can’t type, gets an extension that maps each Server-Sent Event’s name to its schema.

"text/event-stream": {
  "schema": { "type": "string", "description": "Server-Sent Events: ..." },
  "x-smarter-events": {
    "progress": { "$ref": "#/components/schemas/PromptChatApiProgressEvent" },
    "result": { "$ref": "#/components/schemas/PromptChatApiProgressResult" }
  }
}

Generators ignore the extension, so nothing breaks. Clients that read it get typed events.

Tests that keep the document honest

A contract is only as good as the evidence that the platform keeps it. The platform’s contract tests fail when a committed file is stale, when an example manifest doesn’t validate against its own schema, when a real response doesn’t match the document, and when a route exists that the document doesn’t describe.

That last one matters most for an API that grows. The document declares which families of routes it covers, by their Django urlconfs.

DOCUMENTED_URLCONFS: dict[str, str] = {
    "/cli/": "smarter.apps.api.v1.cli.urls",
    "/llm-clients/": "smarter.apps.llmclient.api.v1.urls",
    "/guardrails/": "smarter.apps.guardrail.api.v1.urls",
    "/secrets/": "smarter.apps.secret.api.v1.urls",
    ...
}

And test_every_route_is_documented walks every route of every one of them.

for prefix, urlconf in DOCUMENTED_URLCONFS.items():
    for pattern in importlib.import_module(urlconf).urlpatterns:
        ...
        with self.subTest(route=path):
            self.assertTrue(
                path in paths or path in UNDOCUMENTED_ROUTES,
                f"{path} is not in the OpenAPI document. {FIX_UNDOCUMENTED_ROUTE}",
            )

A route is either in the document or on a list of exceptions with a written reason. A second test does the same for whole families, so a new Django app that mounts routes under /api/v1 fails the build until its routes are described. The few families left out each say why. They are the platform’s own test views, demo data, and the MCP server, whose messages the Model Context Protocol defines, not OpenAPI.

A document generated from the code, and tested against the code, can’t drift from the code.

The view from the other side

Here’s what each client sees, and, just as important, what it doesn’t.

smarter-python generates the data and tests the calls

The Python SDK (pip install smarter-api) copies the platform’s contract files with make contracts, pinned to a platform release with SMARTER_REF=<tag>, and generates a Pydantic model per manifest kind from each schema with datamodel-code-generator.

"datamodel-codegen",
"--input", str(DATA / f"{kind}.schema.json"),
"--input-file-type", "jsonschema",
"--output-model-type", "pydantic_v2.BaseModel",
"--base-class", "smarter.models.sam.base.SAMModel",
"--use-field-description",
"--class-name", f"SAM{kind}",

--use-field-description carries every field’s description from the platform’s Pydantic models into the SDK’s docstrings, so the SDK’s documentation is the platform’s documentation.

What a developer writes looks like this, from the SDK’s README.

from smarter import Smarter

client = Smarter()

client.manifests.apply("pii-redactor.yaml")                    # create or update
manifest = client.guardrails.describe("pii_redactor")          # read it back
guardrail = manifest.to_sam()                                  # as a typed SAMGuardrail
print(guardrail.spec.config.action)

There’s no Django in the SDK’s dependencies, no broker and no queryset. A broker shows up only as the name of an error class, a string in an error response.

The SDK’s tests/test_contracts.py checks the contract in both directions. It calls every SDK method against a mock transport, matches each recorded request to an operationId in openapi.json, and fails if a request isn’t in the document, or if the document has an operation that the SDK doesn’t call and hasn’t said why.

called = {self.operation(request.method, request.url.path) for request in self.requests}
published = {
    item[method]["operationId"]
    for item in self.document["paths"].values()
    for method in ("get", "post", "delete")
    if method in item
}
self.assertEqual(
    published - called, self.NOT_CALLED, "The platform publishes cli operations that the SDK doesn't call."
)

A second test validates responses recorded from a real platform against each operation’s schema, because, in the words of its docstring, “a fixture that drifts from what the platform publishes would let the SDK’s tests pass against a response that the platform never sends.”

smarter-cli, with no generated code at all

The Go CLI shows that a client doesn’t have to generate anything to benefit from the contract. Every command is a POST to /api/v1/cli/<command>/, with the manifest sent as the YAML that was read from disk, from cmd/apply.go.

// this request goes to /api/v1/cli/apply/
if _, err := APIRequest("apply", map[string]string{}, fileContents); err != nil {

The CLI never parses or validates a manifest. The broker does that, on the server. smarter apply --dry-run sends the same body to validate/ instead. The CLI reads only data and message from the response envelope, and its table view takes its columns from the server’s response, so a new field on a resource shows up in smarter get without a CLI release.

The CLI keeps a copy of openapi.json, pinned to a platform release, and contract_test.go runs every command in-process against a mock platform that records its requests. It fails if a request isn’t a documented operation, if it sends a query parameter that its operation doesn’t document, or if it sends a kind that isn’t in the document’s SAMKind enum.

if !known[kind] {
    t.Errorf("%s sent the kind %q, which is not a SAMKind of the platform's OpenAPI document", r.command, kind)

The CLI’s ADR-022 sums up the effect. “A renamed route, a removed kind or a new kind fails a unit test, in CI, rather than reaching users.” And refreshing the copy produces a diff that is “a precise list of the api’s changes.”

Smarter Chat, a widget for any web page

Smarter Chat is the React chat UI of an LLMClient, built two ways from one source, as an npm library and as a single embed script that any page can load.

<script src="https://cdn.jsdelivr.net/npm/@smarter.sh/ui-chat@0/dist/embed.js"
  data-chatbot="https://<your-llmclient-host>/" async></script>

That’s how WordPress, Drupal, Shopify, Google Tag Manager and static site generators all add a chat the same way. The widget renders inside a shadow root, and the only things it knows about the platform are two URLs and the shapes of their JSON, which are an LLMClient’s configuration, and a prompt’s response, as JSON or as a stream of Server-Sent Events.

Those shapes come from the chat contract, chat-contract.schema.json, which the platform generates from the same Pydantic models that supply the chat api’s schemas in openapi.json. Smarter Chat generates its TypeScript types from it with json-schema-to-typescript, and src/contract.test.ts validates every mock response that its tests and Storybook stories use against the schema.

it("progress events and errors", () => {
  for (const event of progressEvents) expect(errors("PromptChatApiProgressEvent", event)).toEqual([]);
  expect(errors("PromptChatApiProgressResult", { status: 200, response: promptResponse() })).toEqual([]);

The same test checks that the generated types are current. A hand-written fixture that drifts from what the platform really sends fails a test, rather than passing silently and lying to every other test that uses it.

In smarter-vscode-yaml, the schema’s descriptions become the editor’s help

The VS Code extension turns a manifest’s JSON Schema into an editing experience, with validation as you type, autocomplete, hover documentation and a “New Manifest” command that starts from the kind’s example. It asks the platform for each kind’s schema at POST /api/v1/cli/json-schema/<Kind>/, caches it, and falls back to a bundled copy when the platform can’t be reached, as its ADR-004 describes.

The hover text is built directly from the schema, in src/core/schema.ts.

lines.push(`**${name}**: \`${typeLabel(root, schema)}\`${required ? " — *required*" : ""}`);
const description = cleanDescription(
  resolved.description ?? alternatives(root, schema)[0]?.description,
);
if (description) lines.push("", description);

So the description a platform developer writes on a Pydantic field in Django is the tooltip a prompt engineer reads in VS Code, with nothing copied by hand in between. Autocomplete offers each field’s enum and const values from the same schema. The extension’s API client, which applies, deploys and describes resources, is typed by openapi-typescript from openapi.json, so a changed operation shows up as a type error, not a runtime surprise.

Its tests check that it bundles a schema and an example for every kind in the document’s SAMKind enum, that every platform example validates with zero problems, and that every key of every example gets hover documentation.

Four clients, and not one of them knows what a broker is.

Easy to maintain, by construction

An extensibility model is only resilient if keeping it current is cheap. Smarter’s contract costs very little to maintain, because almost nothing in it is written by hand.

On the platform side, a developer who changes a SAM model, a broker’s example manifest or a response’s model runs make api-contract and commits the regenerated files. If they forget, a test fails. If they add a route and don’t describe it, a test fails. Adding a whole new manifest kind needs no change to the contract package at all. Register the broker, give it an example, regenerate.

The pull request’s diff of openapi.json is then reviewed as a client would read it, sorted into two columns.

Backward compatibleBreaking for clients
A new optional field, or a new kindA removed or renamed field, kind or operation
A new operationA field that became required, or a narrower type
A relaxed constraint, a new enum valueA removed enum value, a changed default
A changed descriptionA changed response shape

On the client side, each repository runs the same two steps, make contracts SMARTER_REF=<tag> to copy the files from a platform release, then its generator and its tests. A failing contract test after a refresh is how a client learns what changed, and the failure message says exactly where.

Seven-step flow of how an API change reaches every client. The first four steps happen on the platform. 1, change a Pydantic model (a SAM model, an example manifest or a response's model); 2, make api-contract regenerates openapi.json and every sam/<Kind> schema and example; 3, commit the files, so the pull request's diff is reviewed as compatible or breaking; 4, the platform's contract tests fail when a committed file is stale, an example doesn't validate against its schema, a real response doesn't match the document, or a route or family of routes isn't described. The last three happen in each client, pinned to a platform release. 5, make contracts SMARTER_REF=<tag> copies the files; 6, generators such as datamodel-code-generator, openapi-typescript and json-schema-to-typescript run; 7, client contract tests check that every request is a documented operation, every kind a SAMKind, and fixtures match the schemas.

The contract also has limits, and they’re worth naming. It can only be as precise as the schemas. A rule that the platform enforces only in a broker’s code, and not in a model’s types, isn’t in the document, so no client can check it ahead of time. The document doesn’t say which kinds can be deployed, so the CLI checks that against the platform’s resources/ command instead. And because the document is OpenAPI 3.1, a few generators that read only 3.0 can’t use it directly. Smarter keeps 3.1, because that’s the version whose schemas are exactly what Pydantic emits, and would publish a converted copy beside it before it would downgrade the original.

Each of those limits sits on the platform’s side of the seam, where it can be fixed once for every client.

Takeaways

Here’s what to take from Smarter’s contract to your own platform, in rough order of payoff.

  • Put the seam at a standard document, not at shared code. OpenAPI and JSON Schema are the formats that the most languages and tools already read. Your clients’ authors will know them, and so will their tools.
  • Generate the document from the models that build the responses. If your framework has typed models (Pydantic, Zod, serde), export their schemas. A document written by hand describes what someone remembered.
  • Commit the generated files. A change to the API becomes a diff in a pull request that someone reviews, sorted into compatible and breaking.
  • Test that every route is documented. Make undocumented routes a failing test, with a short, honest list of exceptions and their reasons.
  • Test real responses against the document, on the platform and in each client. A mock that drifts from reality makes every test that uses it lie.
  • Let each client pin a version of the contract, and test itself against it in both directions, so that it sends no request the document doesn’t describe and silently ignores no operation.
  • Keep the clients thin. Send manifests as they are, and let the server validate them. The less a client knows, the less it has to change.
  • Write good field descriptions. They become every client’s docstrings, tooltips and API reference.

Closing thoughts

The best evidence that the seam is in the right place is how little the clients need. A Go CLI that never parses a manifest, an editor extension whose help text comes straight from a Django model’s field descriptions, a chat widget that works on any web page, and a Python SDK with no Django in sight. Each one is built with ordinary tools for its own technology, and each one can be rewritten without the platform noticing. Meanwhile, the platform can refactor its brokers, its models and its database as often as it likes, and the only question that matters is whether openapi.json changed. When it does, everyone finds out the same way, from a diff and a failing test, before anything ships.

If you want to build your own client in a language that Smarter doesn’t cover yet, you don’t need anything beyond a platform’s /docs/openapi.json. That’s the point of putting the seam there.

Happy hacking!