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.

Four clients, four technologies, one document
| Client | Language | What it reads from the contract | What it does with it |
|---|---|---|---|
| smarter-python | Python | openapi.json, every sam/<Kind>.schema.json | Generates a Pydantic model per manifest kind; tests every SDK request against the document’s operations |
| smarter-cli | Go | openapi.json | Tests that every command maps to a documented operation and every kind it sends is a documented kind |
| Smarter Chat | TypeScript, React | The chat contract’s JSON Schema, from the same models as the document’s chat api | Generates its TypeScript types; validates its test fixtures against the schema |
| smarter-vscode-yaml | TypeScript, VS Code | openapi.json, and each kind’s schema from the live json-schema endpoint | Generates 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.
- datamodel-code-generator turns each kind’s schema into Pydantic models for the Python SDK.
- openapi-typescript turns
openapi.jsoninto the VS Code extension’s API types. - json-schema-to-typescript turns the chat contract into Smarter Chat’s types.
- Ajv and Python’s jsonschema validate manifests, fixtures and recorded responses.
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 whatPOST /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.

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 compatible | Breaking for clients |
|---|---|
| A new optional field, or a new kind | A removed or renamed field, kind or operation |
| A new operation | A field that became required, or a narrower type |
| A relaxed constraint, a new enum value | A removed enum value, a changed default |
| A changed description | A 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.

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!
- Smarter on GitHub
- The Smarter OpenAPI Contract
- APIs and SDKs
- ADR-031: Pydantic Models Over Raw JSON
- ADR-024: Broker Model
- smarter-python, smarter-cli, smarter-vscode-yaml
- Getting Started With smarter-api, the Smarter Python SDK
- Getting Started with Smarter’s CLI
- Getting Started with @smarter.sh/ui-chat
- The Smarter blog
