Extending Supero: your own endpoints, your own logic, your own UI
A platform is only useful if you can leave it. Four escape hatches, and the order to reach for them in.
Supero has four extension points — the services catalog, `extends`, the plugin registry, your own `ui/app.js` — and none of them opts you out of tenancy or RBAC. This is the decision rule for which one a given problem calls for.
The question every evaluator asks second
Teams hand-roll a cart because they did not know cart existed. They register a plugin for Stripe that the catalog already has. They fork the whole UI to change one card layout. Each is the same mistake made at a different altitude, and it is the mistake this article exists to prevent: Supero has four extension points, and the useful part is not the list, it is knowing which one a given problem calls for.
The first question anyone asks is "can it build my app". The second — the one that actually decides the purchase — is "what happens when it cannot". Every platform has a wall; what matters is whether the wall has a door, and whether going through it costs you the platform's guarantees.
Work down the list, and stop at the first yes
| If what you need is… | Reach for | You keep |
|---|---|---|
| A third-party service someone else also uses (Stripe, Twilio, SendGrid, Okta, S3…) | The managed services catalog | Credentials in a store, retries, one call shape, no SDK |
| A lifecycle with states — a cart, an order, a booking, an approval, a signature | extends on a transactional base schema | A tested state machine, idempotency classes, an audit trail |
| A vendor not in the catalog, or one of your own internal HTTP services | The plugin registry | Stored auth header, retries, a circuit breaker, invocation stats |
| A screen the generated UI does not produce | ui_schemas.py, then ui/app.js | The same data client, services and access rules underneath |
The plugin registry — your endpoint, with guardrails
A plugin is a record of one HTTP endpoint plus the retry and breaker behaviour you would otherwise write yourself: method, timeout, an optional auth header held on the platform side, retry counts and circuit-breaker thresholds. Plugins live at the domain level — you register one against a project you have access to, and every project in that domain can then resolve and invoke it by name.
{"object": { …the fields you sent… }, "metadata": { request_id, timestamp }} — plus an X-Request-ID header and your stored auth header. Read your fields off object; this is the first thing every integration gets wrong. And note what the wrapper buys you: the credential never travels through your application code, so it is the difference between "we call an internal API" and "every service that calls it needs the key".curl -X POST https://api.supero.dev/api/v1/plugins/register \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"project_uuid": "8f14e45f-2d3a-4b1c-9e6a-1234567890ab",
"plugin_name": "internal-sms-gateway",
"endpoint_url": "https://sms.example.com/api/send",
"http_method": "POST",
"auth_type": "header",
"auth_header_name": "X-Api-Key",
"auth_header_value": "REDACTED"
}'
# then, from anywhere: object_type is just a label for the logs
curl -X POST https://api.supero.dev/api/v1/plugins/invoke/sms_notification \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "plugin_name": "internal-sms-gateway",
"to": "+15551234567", "body": "Your order has shipped" }'| Field | Default | What it buys you |
|---|---|---|
http_method | POST | One of POST, PUT, PATCH |
timeout_seconds | 30 | 300 is the documented ceiling — set it to what your endpoint really needs; nothing caps it for you |
auth_type / auth_header_name / auth_header_value | none | The credential lives here, not in your code, and is redacted on read |
custom_headers | — | Static headers sent with every call |
max_retries / retry_backoff_seconds | 3 / 2 | Exponential backoff (backoff × 2^attempt), so a blip does not become an incident. A 4xx is never retried — only failures worth retrying are |
circuit_breaker_threshold / _timeout_seconds | 5 / 60 | A failing dependency stops being hammered while the breaker is warm. It is per-process and armed at registration, so treat it as a fast local guard — max_retries and timeout_seconds are the settings that always apply |
enabled | true on create | Not a register-body field — flip it with PUT /api/v1/plugins/{uuid}/status. Disabled invocations are rejected with 503: a kill switch you can hit without a deploy |
A GET on the plugin also returns what you would otherwise have to instrument yourself: health_status, total_invocations, success_count, failure_count, last_invocation_at and average_latency_ms. GET /api/v1/plugins/{uuid}/health gives the same picture plus the circuit-breaker configuration.
The UI — declarative, then hand-built, never both
Supero renders an app's UI one of two ways, and a single app uses exactly one. The declarative path is a UI_SCHEMAS list in ui_schemas.py — pure data extending the platform's UI grammar, covering per-entity admin screens, dashboard widgets, landing pages and the app shell. The SDK renders all of it; ui/app.js becomes a thin shim.
The hand-built path drops ui_schemas.py and writes ui/app.js yourself — either calling AppShell.render({tabs, appName, theme, …}) with your own tab definitions, or mounting your own React tree for full control.
Whichever path you take, the primitives underneath are the same: the same data client, the same services.* calls, the same access rules. Hand-building the UI does not opt you out of tenancy or RBAC — those are enforced server-side, so a hand-rolled screen is exactly as governed as a generated one.
Your own fields, including on the platform's own objects
extends is the extension point people notice last and use most. It works in two directions.
- Down from a service base —
extends: "cart:base_cart"inherits a whole lifecycle and lets you add your own fields on top. This is how you get a state machine you did not write; the transactional catalog lists the base schema for every service. - Onto a system object —
extends: "general:project"adds custom fields to a platform entity without touching the base. Your own metadata on a project, tenant or user, carried through the same APIs as everything else. - Between your own schemas — put shared fields on a base of your own and inherit from it. Resolution is server-side, so clients never walk the chain; they see the merged shape.
Your own logic — and where it should run
There are three honest places to put business logic, and the choice is mostly about who has to be able to change it.
A workflow
Declarative steps — call a service, write a record, branch, run in parallel, reshape. Every run and every step is a queryable record. Best when an operator may need to change the behaviour without a deploy.
Your own backend
A service you host, registered as a plugin and invoked from your app code or a script. Best when the logic is genuinely yours — pricing engines, proprietary models, legacy systems.
Your app's own code
In ui/app.js or a script against the SDK, calling the same CRUD and service APIs. Best for anything user-facing and interactive.
For AI-shaped logic inside your own features — a "summarise this" button, a draft-reply field, a classification step — services.ai is the catalog entry to import. Keep it distinct from the in-app agent: the agent is a finished surface for end users, services.ai is a raw call for your code, and wiring one through the other only adds a failure mode.
Adding your first plugin, end to end
Check the catalog before you build anything
curl https://api.supero.dev/api/v1/services/catalog \
-H "Authorization: Bearer $ACCESS_TOKEN"
# and, for one entry's operations and config fields
curl https://api.supero.dev/api/v1/services/catalog/email \
-H "Authorization: Bearer $ACCESS_TOKEN"The catalog is the authoritative list of what is already available and what is already imported for the project. Most "we need a plugin for X" turns out to be a service_id someone did not know about.
⏱ ~10 minutes
Register the endpoint, with the breaker settings you actually want
The defaults are sensible but generic. Think about two of them specifically: timeout_seconds should reflect what your endpoint really does — a 30-second default in front of a 2-second API turns a hang into a stall — and circuit_breaker_threshold should be low enough that a dead dependency stops being hammered before your on-call notices.
⏱ ~20 minutes
Invoke it from one place, not from every caller
Put the invoke behind a single helper in your app rather than scattering it. The registry is then the one place the credential lives and the one place the invocation stats accumulate — which is what you will want the first time someone asks why the legacy API did not get called.
If the trigger is a data change, a workflow can still drive it indirectly: bind the event to a workflow whose step calls your code, and let that code invoke the plugin.
⏱ ~30 minutes
Watch the numbers the registry is already collecting
An extension point you cannot see the health of becomes the thing everyone blames and nobody checks — and the registry is already collecting health_status, success_count/failure_count and average_latency_ms for you. The kill switch is worth knowing too: PUT /api/v1/plugins/{uuid}/status with {"enabled": false} takes a failing dependency out of the path without a deploy.
⏱ ~15 minutes
What extending does not get you
- There is no plugin step type in workflows. A
service_callstep resolves against the managed services catalog, so a data-triggered call to your own endpoint needs a workflow step that calls code of yours, which then invokes the plugin. This is the limitation you will actually hit. - Plugins are outbound HTTP calls with
POST,PUTorPATCH. They are not a way to host arbitrary code inside the platform. - A plugin is registered once per domain and is resolvable by name from any project in it — convenient, but it means the name is a domain-wide namespace, not a per-project one.
- The runtime UI files are regenerated on every run. If you find yourself editing them, you are on the wrong path — move the change into
ui/app.jsorui_schemas.py. - Extending a base schema inherits its state machine, which means you also inherit its rules. If your lifecycle genuinely differs, model your own entity rather than fighting a base that nearly fits.
A door in the wall, not a cliff
The usual failure of an application platform is binary: inside the box everything is fast, and one step outside you are writing a second system with none of the guarantees. What makes extension work here is that the escape hatches keep the foundation — a plugin call still has its credential managed for you, a hand-written screen is still tenant-isolated and role-enforced, a custom field on a system object still travels the same APIs.
Which means the honest advice is not "use the platform for everything". It is: work down the list, stop at the first yes, and know that going one level lower costs you convention rather than governance.
Build this on Supero
Describe it and get schemas, APIs, SDKs and a governed multi-tenant backend — or point your own AI editor at Supero over MCP and build it there.