Skip to content
Chandler Nguyen
AI14 min read

I Gave My 9-Agent Marketing Platform Away as Open Source

STRAŦUM v1 is now MIT-licensed and public. Nine agents, two Postgres schemas, ten locales — and a table that shipped with Row Level Security switched off, reachable by anyone holding the key that loads in the browser. Here is what I gave away, what I would not build the same way again, and why you are welcome to fork it and build your own.

I open-sourced the first version of my marketing platform today.

It is called STRAŦUM. Nine AI agents, an agency workspace with per-client data separation, ten languages. I built it in 2025 while I was teaching myself to write software. Then I started on a second version, and rather than keep the first one locked away, I have given it away.

If you want to look before reading the rest: github.com/chandlernguyen/stratum-oss

git clone https://github.com/chandlernguyen/stratum-oss

If you run marketing teams and will never open that repository, skip to "If you will never read the code" near the end. That section is for you and it is short.

I want to be precise about what that repository is, because "open source" can mean a lot of things and most of them overstate what this is.

What I actually published

STRAŦUM v1 is published and not actively developed. The code is public under the MIT licence. There is no roadmap, no release schedule, and no support commitment — the README says so, and the contributing guide says it again.

It is published as a reference implementation. It is a thing to read, or to fork, or to take pieces from. It is not a thing to depend on. Nobody is maintaining it against future dependency changes, and the repository lists the open advisories it knows about rather than pretending they are not there.

What I am not saying is that it will never change. I am not developing it further, so you should plan as if it stays as it is — but I do read what people send me, and if someone reports a bug or suggests something that makes the code clearer, I am not going to pretend I did not see it.

Publishing it also meant proving nothing confidential went with it. There is a script in the repository that scans every tracked file for credentials, private keys, provider tokens, and production identifiers, and it runs as the first job in CI — before the tests, because a leaked key is in the history the moment it is pushed and deleting it afterwards does not undo that.

Here is what is in it:

  • Nine agents — strategy, persona, content, performance intelligence, competitive intelligence, campaign planning, client success, and two more. Each is a subclass of a base agent, sharing a prompt structure, a tool registry, and progressive context.
  • Two Postgres schemas. Data for individual businesses lives in one, agency data in another, with Row Level Security as the isolation boundary.
  • Ten locales, with the locale travelling to the API in a header so that generated metadata matches the interface language.
  • A demo mode that needs no API key. The agents return clearly labelled canned output instead of calling a model, so you can click through the whole application without a provider account.

If you want to build your own version of this

This is the use I would most like to see, so it is worth saying plainly rather than leaving it implied.

If what you actually want is your own marketing agent system — your own agents, your own prompts, your own schema, your own product on top — then fork the repository and build it. That is what the MIT licence is for, and it is the intended use, not a loophole. There is no attribution requirement beyond keeping the licence file, and no need to check with me. Take the parts that are useful, throw away the parts that are not, and change whatever you disagree with.

I would rather see a dozen different versions of this than one version that only I ever touched.

Why publish v1 instead of keeping it to myself

I sat on it for ten months because I assumed nobody would want a v1 that I had already stopped working on. That assumption was wrong, and the reason it was wrong is worth more than the repository itself.

The second version diverges. What I am building now does not resemble this codebase closely, and it will not be a diff against it. Keeping v1 private would mean it slowly became a museum piece nobody could visit.

The better reason is that a working multi-tenant agentic application is a more useful artefact than an architecture diagram. When I was learning, what helped was not the conceptual explainer — it was finding a real project and reading how somebody else had actually arranged it. The reasoning that gets lost in the explainer is the reasoning that matters most.

I wrote four things in the repository's architecture notes that I got wrong and would not do again, and I left all four in. The first one is a security bug.

The mistake that shipped

One table went out without Row Level Security switched on.

public.notification_push_deliveries was created without the line that enables RLS, and granted to the anonymous and authenticated roles with full privileges — including TRUNCATE — with no policies defined at all. It sits in the schema the Data API exposes, and the anonymous key ships inside the browser bundle. So for that period, anyone holding that key could read the table or modify it. It holds push device identifiers, which made it a data-exposure problem rather than a theoretical one.

I found it in a security review. Not in a bug report, and not in testing, because the application worked perfectly the entire time. Nothing throws an error when a table is missing RLS. The queries succeed, the feature works, and the permission that should not exist is simply there, doing nothing visible until somebody looks.

It is closed now. RLS is enabled on the table with no policies, and the browser roles have had their grants revoked, so the request that would once have returned rows comes back as a permission error. I am describing it in the past tense deliberately: this is what the code looked like, not what it looks like today.

That is the part I want to be precise about: this was not a design decision I got right. It was a regression I got wrong and found afterwards.

So I stopped trusting my memory

What I did next is the only piece of this I would confidently hand to someone else.

I wrote a test that does not name any particular table. It walks the schema and asserts the rule: every table in an exposed schema has Row Level Security enabled, and no tenant-scoped materialised view is readable by a browser role. If I add a table next month and forget, the test fails, and it fails on the thing I forgot rather than on a list I wrote when I remembered.

Two honest caveats, both of which I would want a reader to hold against me rather than discover later:

  • The materialised-view half of that check covers the public schema and not the agency schema. The agency side has its own exposure I have not closed.
  • The test skips itself when it cannot reach a database. A skipped test is green, which means a broken environment can hide a failing invariant.

If I took one habit from all this, it is this: when I find a bug of this shape, I write the test that would have caught it — and I write it against the rule rather than against the object. A test naming the three objects I remembered only protects those three. A test that asserts the rule protects the one I add next week and forget about.

The second trap

There is a second one that I got right by luck rather than by design, and it is the one I hear repeated in vendor conversations.

A client-side filter is not a boundary. If your application filters rows by organisation in the browser — .eq('org_id', ...) and friends — then the isolation runs on the user's machine, which means it can be removed with developer tools. Anything removable is a display preference, not a security control. Row visibility has to be enforced either in the database or behind a server endpoint the caller cannot reach around.

Materialised views are the awkward relative of this problem. They cannot have Row Level Security at all. A SELECT grant on a materialised view returns every tenant's rows, and it looks identical in a migration to the same grant on a table, where policies would still constrain it. The repository revokes browser-role grants on anything tenant-scoped that gets materialised for exactly that reason.

The one I have not finished

Row Level Security cannot restrict TRUNCATE. It is a table-level privilege, not a row-level one, so policies do not apply to it. The repository's baseline grants hand the browser roles ALL on a large number of tables — which includes TRUNCATE — and nothing narrows that afterwards. The materialised-view grants are revoked. These are not. So there are tables where a browser role holds a privilege that sits outside the boundary I have just spent this section describing.

In practice it is latent rather than open: the Data API has no TRUNCATE verb, the browser roles cannot connect to the database directly, and a normal deployment does not expose the database port. It is a hygiene problem. But it is the same shape as the bug at the top of this post — a privilege that quietly outlives its justification — and I would rather point at it than let a reader find it and wonder whether I knew. It needs a revoke across both schemas, and a test asserting that no browser role holds a privilege which bypasses RLS.

What I would not do again

Two parallel schemas duplicated a lot of DDL. Agency and individual-business data have parallel structures instead of one table with a tenant discriminator column. The separation is genuinely cleaner. The duplication cost more in maintenance than the discriminator column would have, and I would make the other call next time.

Manual function calling is a lot of code. The agent loop handles tool calls by hand rather than using the provider's automatic function calling, which keeps streaming and tool execution under application control. The cost is that the application has to assemble function-result parts itself, and on the current models those parts must carry the call id as well as the function name. Omit the id and you do not get a schema error. You get something that reads like the model being flaky, which is a much worse afternoon. If streaming with tools is available through a higher-level API now, that trade is worth revisiting.

The migration chain reached 321 files. A large share of them named fix_, _v2, and remove_, because I was appending corrections instead of editing the thing I had already written. The schema's true state was only knowable by replaying history from the beginning. The posts I wrote at the time were more confident about that period than the code deserved — in November 2025 I said thirty-three migrations had "finally solved" multi-tenancy, and then kept writing corrective migrations for months afterwards.

The frontend trusts the API's shapes without validating them. There is careful validation on the way in — the backend parses every request with Pydantic — and none on the way out. The browser takes the response and believes it. A shared schema would catch the drift when a field changes shape, instead of leaving it to be discovered as a blank screen. Combined with the three above, that is the four things the architecture notes say I would do differently.

The published repository rebuilds the migration chain as twenty layered migrations, grouped by concern — tables, then functions grouped by domain, views, indexes, triggers, policies, grants, scheduled jobs, and a final hardening pass. That is roughly true rather than exactly true, and it is worth being honest about where it slips: one of the twenty is an admitted catch-all for functions that did not classify, and the table layer is not split the way its filenames suggest. The file named for the shared schema also creates the eight agency tables, and the file named for the agency schema contains no table definitions at all. The layering is real; the labels are not perfect.

What the split does get right is the part that matters for reading it: the schema is a pure reorganisation, verified by dumping it before and after and confirming the two are byte-identical apart from the dump tool's random token.

That verification is the only reason I was willing to touch it at all.

What survived

Row Level Security as the actual boundary, rather than a feature to switch on. Once the design is arranged around it, isolation stops being a checklist item and becomes a property of the system.

Writes go through routed database functions. Application code does not choose which schema to touch. It calls a function that inspects the organisation type and dispatches, so the choice lives in one place where a new code path cannot forget to make it.

Signup ignores client input. The provisioning trigger does not honour a client-supplied organisation id or role, because that data is user-controlled. It builds a fresh organisation and a default owner role. Small thing, easy to get wrong, expensive when you do.

Lazy client construction. Services build their provider client on first use rather than at import. That sounds like a style preference and it is not: several services are created at module import, so eager construction meant that importing the application required an API key, and the failure surfaced as an opaque error from the provider's SDK before anything could start. Making it lazy is what makes the demo mode possible, which is what lets a stranger evaluate the project at no cost.

That last one is the decision I am happiest with, and I did not make it for the reason it turned out to matter. I made it to stop the crash.

If you will never read the code

This is the section I would have wanted when I was on the agency side buying this kind of software, so there is no code in it.

The thing worth taking away is the difference between "we filter by account" and "the database cannot return another account's rows."

I have bought platforms for most of my career and built them for the last few years, and that distinction is the one I did not understand until I had shipped the wrong version of it. One is a rule the browser follows. The other is a rule the browser cannot break. Most tools have the first and describe it in the language of the second.

When you are evaluating a platform that will hold several clients' data — competitive intelligence, performance data, audience definitions, whatever it is — the question to ask is not "is it secure." Everyone says yes to that. The question is: where is the separation enforced, and what happens if a developer removes the filter?

There are two good answers. It is enforced in the database by policies tied to the signed-in user's identity, or it is enforced behind a server endpoint the browser cannot reach around. Any answer that involves the browser is a no.

Good answers sound like: the policy is on the table and keyed to the user's session; the browser never queries that table directly; here is the test that proves it. Less useful answers sound like: our application filters by account; the data is encrypted; we are SOC 2 compliant. Those may all be true and none of them answers the question.

It is one line, and it fits in a vendor security review, which is where I would put it. The answer tells you whether multi-tenancy was designed in or retrofitted.

Frequently Asked Questions

Why publish it instead of letting it sit in a private repository?

Because a public artefact can be checked and a private one cannot. The earlier posts I wrote about building this made claims; a repository with the migrations, the policies, and the tests is something a reader can verify, including the parts I got wrong. The architecture notes have a section called "Tradeoffs, and what I would do differently," and it is the reason the repository exists in this form.

Will the second version be open source?

No. It is developed privately, and the codebase is not expected to resemble this one. I would rather say that plainly than leave it ambiguous and have people clone this expecting a roadmap.

Can I use this in production?

I would not. It is a reference, not a product. The demo credentials in the seed data are for local use only, there is no support commitment, and nobody is fixing it against future dependency changes. It is a good thing to read and borrow from, and a bad thing to run a business on.

Do I need an AI key to try it?

No. It starts in demo mode, where the agents return clearly labelled canned output instead of calling a model. You can click through the entire application — every agent, the agency client flows, the language switcher — without a key and without spending anything. Switching to real model calls is one setting and one key. The first run took me more than ten minutes: it needs Docker running, Node, Python, Poetry, and the Supabase CLI, and the slow part is a large download.


I wrote a lot about building this while I was building it, and the two posts I would start with are why I built multi-tenancy on day two and what happened when I rebuilt it on day sixty-seven. If only one of them is worth your time, it is the second — it is the one where the architecture turned out to be wrong.

The code is at github.com/chandlernguyen/stratum-oss, and the test that guards the mistake I shipped is in tests/automated/test_rls_coverage.py.

If you have shipped a multi-tenant system and found a third silent trap I did not mention, I would genuinely like to hear about it — those are the ones worth collecting.

Feedback, suggestions, and what happens to v1

I would rather this not be a one-way broadcast, so this is the honest version of what to expect.

What I would welcome: bug reports if something in the repository is simply wrong. Suggestions on the parts that could be clearer, or simpler, or done with less code. Notes from anyone who has tried to run it and hit something the README does not cover. Pull requests, if you find a genuine problem and want to fix it. And if you fork it and build something of your own, I would like to know what you changed and why — that is the most interesting feedback of all, because you had to make the decisions for real.

What I can promise: not very much, and I would rather say that than imply otherwise. This is not an actively developed project, I am not running a support desk, and I cannot commit to a response time. Some suggestions I will act on. Some I will read, agree with, and never get to. That is the realistic version of a side project that already has a successor.

What happens to v1: it stays published as it is. I am not developing it further, so do not plan around new releases. But I am not going to pretend it is sealed — the code is public, the licence allows you to take it in any direction, and if something is broken or genuinely unclear, there is no good reason for me to leave it that way on principle.

The simplest way to reach me is an issue on the repository, or email if you would rather not be public about it.

That's it from me for now.

Cheers, Chandler