Most AI builders write code. Ours configures an engine.
$npm i @lynqly/runtimeThe logic in your app is data: a config the engine checks before it runs, on the same engine every app runs on. Below: the architecture, a real config, the guarantees and where they stop, and the license, said precisely.
{ "id": "book_class", "exec": { "v": 1, "on": { "trigger": "form_submit" }, "steps": [ { "id": "book", "action": "transact", "steps": [ { "id": "seat", "action": "update_record", "collection": "classes", "target": { "from": "input", "ref": "class_id" }, "incs": { "seats": -1 }, "guard": { "field": "seats", "op": "gte", "value": 1 }, "guardMessage": "Sold out. No seats left." }, { "id": "booking", "action": "save_record", "collection": "bookings", "fields": { "class": { "from": "input", "ref": "class_id" }, "name": { "from": "input", "ref": "name" }, "email": { "from": "input", "ref": "email" } } } ] }, { "id": "confirm", "action": "email", "to": { "from": "input", "ref": "email" }, "autoReply": true } ] }}
The AI writes pages and configs. One engine runs them all.
There is no generated backend per app. The AI writes your pages, real HTML and CSS in your brand, and describes what should happen as data. One engine, the same code for every app, checks that data and runs it against Postgres.
Real HTML and CSS. Brand colours by variable, so a palette change re-themes every page.
A trigger and up to 20 steps, with bindings to the form, the record or the member. Data, not code.
Unknown actions, unsafe shapes and prices taken from a form are refused.
Records as JSON documents, row-level security forced on.
Sent from the steps; payments on the owner’s own Stripe account.
The escape hatch, in a sandbox. Section 08.
A config can be validated. A generated codebase can only be read, by someone, later.
Every app runs the same engine, so a fix or a new capability lands for all of them at once.
A feature is a page of config, not a new service. Fewer generated lines, fewer places to break.
One feature, all the way down.
Book a seat in a class, never sell the same seat twice, confirm by email. This is the whole backend for it, and what Postgres does with it.
{ "id": "book_class", "exec": { "v": 1,1 "on": { "trigger": "form_submit" }, "steps": [ { "id": "book", "action": "transact", "steps": [2 { "id": "seat", "action": "update_record", "collection": "classes", "target": { "from": "input", "ref": "class_id" }, "incs": { "seats": -1 }, "guard": { "field": "seats", "op": "gte", "value": 1 },3 "guardMessage": "Sold out. No seats left." }, { "id": "booking", "action": "save_record", "collection": "bookings", "fields": { "class": { "from": "input", "ref": "class_id" }, "name": { "from": "input", "ref": "name" }, "email": { "from": "input", "ref": "email" } } } ] }, { "id": "confirm", "action": "email",4 "to": { "from": "input", "ref": "email" }, "autoReply": true } ] }}
UPDATE app_data SET data = jsonb_set(data, ARRAY[$1], to_jsonb((data->>$1)::numeric + $2)) WHERE app_id = $3 AND env = $4 AND collection = $5 AND id = $6 AND (data->>$7)::numeric >= $8 -- the guard-- 1 row = seat taken, carry on-- 0 rows = stop: "Sold out. No seats left."
Shape, trigger, every action and binding. This exact config: ok.
Each submit carries a run id. A retry of the same submit claims the same id and does nothing (ON CONFLICT DO NOTHING).
The guard is inside the UPDATE’s WHERE clause. Two people, one seat: the second update matches 0 rows, the transaction rolls back, and they see “Sold out.”
An auto-reply to the address on the form, from a fixed template, capped per app per day.
Every word the engine knows. All of them.
A behavior is one trigger and up to 20 steps, and the routines it calls can take one run to 100. These are all the triggers and all the actions there are. Anything else is refused by the validator, or written as your own code in a sandbox (section 08).
Data8
Money7
Accounts10
On screen8
Reach4
Time3
Control3
AI1
Files2
Compute1
Reserved3
What it guarantees. And where that stops.
Each promise below is a mechanism in the runtime. The third column is where that mechanism ends.
WHERE clause, every value a bound parameter. 0 rows means the run stops with your message.One record, one incremented field per guarded update.transact runs 2 to 6 saves, updates or deletes in one Postgres transaction. A failed guard rolls the group back.Steps outside a transact run in order; a failed step is logged and the run carries on.ON CONFLICT DO NOTHING. Payments carry an idempotency key to Stripe.At-most-once: if a run dies halfway, the retry won’t finish it. Not exactly-once.FOR UPDATE SKIP LOCKED and retry up to 8 times; run ids are deterministic.Chains stop 3 deep, so one behavior can’t trigger itself forever.The AI that builds your app holds no keys.
It has no database credentials, no shell and no web access, and none of its tools can name an app, a user or a job. What it hands back is checked by machines, then used by a second AI that tries to break it.
In the room
Everything it proposes beyond pages (behaviors, data types, plugins) is re-checked by the server before it lands.
Not in the room
It refuses to start if a database URL is present. Its tools take no app, user or job id, so it can’t ask for anyone else’s.
Deterministic checks: every form wired to a behavior that exists, input names that match their bindings, no hand-written data calls, brand colours by variable, text contrast at 4.5:1. One chance to self-correct, then it stops.
The same validator that will receive the build, run as a dry run while the builder can still fix things. It keeps going while the error changes and stops when the same error repeats.
Opens the app at phone size (390 × 844), clicks, types, submits, checks the record in your Admin and the email that would have gone out. It must file a verdict; no verdict counts as a fail.
Every build is a saved version. Nothing reaches your live app yet.
Publishing moves one pointer to that version; restoring moves it back. Every move is logged.
Your app builds one change at a time, so two builds can’t race; other people’s apps build in parallel. On a fixed-price build, a failed verdict refunds itself.
Walls in the database, not only in our code.
Owner, member and public reads run under Postgres row-level security, forced on and failing closed when the tenant isn’t set. The engine’s own steps run with explicit app and member filters. Secrets, outbound calls and money each get a wall of their own.
Row-level security, forced
Forced on the tenant tables. The tenant is set per transaction; unset means zero rows, not every row.
Two keys to one row
A member’s read must match both the owner and the member. Credits and entitlements are system-write only: nobody writes their own balance.
No way to read them back
Envelope encryption: a per-app key (HKDF), a per-secret AES-256-GCM key, the app id bound in. There is no read API. Every use writes an audit row first, or doesn’t happen.
Declared hosts only
https on 443 to endpoints the owner declared. DNS resolved twice and pinned; private and metadata addresses blocked; every redirect re-checked, 3 at most.
The truth comes from the source
Payments run on the owner’s own Stripe account, webhooks signature-checked. An App Store or Google Play notice is a doorbell: we re-fetch the truth with the owner’s credentials.
Nothing useful in a dump
bcrypt at cost 12. Session tokens stored only as SHA-256 hashes. Locked for 15 minutes after 5 failed tries.
The day our own policy leaked.
Before launch we pointed a 143-probe adversarial battery at our tenant walls. It found a real cross-tenant read in our first policy: an uncorrelated subquery on site versions. We fixed the policy and kept the battery. It passes every probe on production.
When the vocabulary runs out, write JavaScript.
Custom code ships inside a plugin manifest: the services it may call, the secrets it needs, what it may do with data. The platform checks the manifest, then runs the code in an isolate with no network of its own.
{ "id": "mailchimp_sync", "name": "Mailchimp sync", "category": "marketing", "version": 1, "secrets": [{ "key": "api_key", "label": "Mailchimp API key" }], "egress": [{ "id": "mc", "host": "us21.api.mailchimp.com", "pathPrefix": "/3.0/" }], "provides": { "customEvents": { "add_subscriber": { "signature": [{ "name": "email", "required": true }], "body": { "steps": [{ "id": "run", "action": "run_isolate", "pluginId": "mailchimp_sync", "args": { "email": { "from": "args", "ref": "email" } } }] } } } }, "worker": { "scopes": ["egress:request", "log:write"], "code": "async function handler(input, ctx) { … }" }}
Its own V8 isolate with no bindings at all: no database, no keys, no network. 100 ms of CPU, 20 subrequests, 512 KB of code.
Every fetch() it makes goes to one place, our mediator. Any other address is refused.
Checks a token signed for this one app, good for 120 seconds, and the scopes granted right now. Four exist: read data, write data, call an endpoint, log.
Only hosts the owner declared, https on 443, DNS pinned, private addresses blocked. The secret is attached after those checks and scrubbed from the reply.
An integration already installed in your app.
One from the shared library.
A new plugin. Custom code is the last rung.
It goes live with the build, no review queue. Change the code or its scopes and it’s re-approved from scratch. A person looks at it only when it’s promoted to the shared library for everyone.
Leave whenever you like. The engine comes too.
One export is a repo that runs anywhere Docker runs: your pages, your data, the runtime and the database setup, row-level security included.
FSL-1.1-MIT. MIT on a timer.
Use it, read it, change it, run it for your business. The one thing it doesn’t allow is using it to offer a competing product. Two years after each release, that release becomes plain MIT. We call it Fair Source, never open source, because for its first two years it isn’t.
An encrypted zip and a one-time link that expires in 15 minutes, after you re-enter your password. On every plan.
Tick one box at export and their password hashes travel with the data. Leave it off and they set a new password by email. Either way, everyone signs in once more.
Custom-code bundles, payment and activity history, and scheduled jobs that haven’t run yet.
What it won’t do. On purpose, and not yet.
Every architecture is a set of trade-offs. These are ours, and the edges that aren’t built yet, so you find them here and not in week three.
On purpose
47 actions. Past them is your own JavaScript in a sandbox, or it isn’t built yet. That limit is what makes every config checkable.
No framework to eject from, and no React app to hand-edit either. Anything a browser runs, your pages can be.
Records are JSON documents in Postgres. Field types are declared, not enforced on write; validate in the behavior when it matters.
20 steps per behavior, 100 per run, loops of 25 rows, chains 3 deep. Heavy batch work belongs somewhere else.
Changes to the same app queue up, so two builds never race.
Not yet
Three ways to build with AI. Pick by architecture.
Each approach is a real choice with a real cost. Here is how they differ underneath, our column included.
Good questions. Before you trust it.
Is this a wrapper around a language model?No
ai_generate), and that step is typed, capped per run and has a fallback.Why configs instead of generated code?Fact
Can I read the engine’s source?Yes
@lynqly/runtime under FSL-1.1-MIT, and every export includes it.Is it open source?No
What happens to my app if Lynqly disappears?Fact
docker compose up: your pages, data, members and the runtime, on your own machine. And two years after each release, that release is MIT.Is anything exactly-once?No
How are customers kept apart?Fact
Does the AI see my customers’ data?Fact
ai_generate step sees only what its config hands it, and its usage receipts never store visitor data.Which AI models do you use?Fact
What can’t it express?Fact
Where does my data live?Fact
Can I call my app from my own code?No
Build something small. Then read what comes out.
Every behavior it writes is a config you can open. Every app can leave with you. Start with a plan; it’s free.
Free to plan · export on every plan