Zum Inhalt springen
Chandler Nguyen
KI14 Min. Lesezeit

Ich habe meine 9-Agenten-Marketing-Plattform als Open Source verschenkt

STRAŦUM v1 steht jetzt unter MIT-Lizenz und ist öffentlich. Neun Agenten, zwei Postgres-Schemas, zehn Sprachen — und eine Tabelle, die ausgeliefert wurde, ohne dass Row Level Security aktiviert war, erreichbar für jeden, der den Key besitzt, der im Browser geladen wird. Hier ist, was ich hergegeben habe, was ich nicht noch einmal genauso bauen würde — und warum du es ruhig forken und deine eigene Version daraus bauen darfst.

Ich habe heute die erste Version meiner Marketing-Plattform als Open Source veröffentlicht.

Sie heißt STRAŦUM. Neun KI-Agenten, ein Agentur-Workspace mit getrennten Daten pro Kunde, zehn Sprachen. Ich habe sie 2025 gebaut, während ich mir selbst beigebracht habe, Software zu schreiben. Dann habe ich mit einer zweiten Version angefangen, und statt die erste wegzuschließen, habe ich sie hergegeben.

Wenn du einen Blick hineinwerfen willst, bevor du weiterliest: github.com/chandlernguyen/stratum-oss

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

Wenn du Marketing-Teams führst und dieses Repository nie öffnen wirst, spring zu „Wenn du den Code nie lesen wirst" gegen Ende. Der Abschnitt ist für dich, und er ist kurz.

Ich will präzise sein, was dieses Repository ist, denn „Open Source" kann vieles bedeuten, und die meisten dieser Bedeutungen versprechen mehr, als hier tatsächlich drinsteckt.

Was ich tatsächlich veröffentlicht habe

STRAŦUM v1 ist veröffentlicht und wird nicht aktiv weiterentwickelt. Der Code ist unter der MIT-Lizenz öffentlich. Es gibt keine Roadmap, keinen Release-Zyklus und keine Support-Zusage — das steht im README, und der Contributing Guide sagt es noch einmal.

Es ist als Referenzimplementierung veröffentlicht. Es ist etwas zum Lesen, zum Forken oder um sich einzelne Teile herauszunehmen. Es ist nichts, worauf man sich verlassen sollte. Niemand pflegt es gegen künftige Dependency-Änderungen, und das Repository listet die offenen Advisories, die es kennt, statt zu tun, als gäbe es sie nicht.

Was ich damit nicht sage: dass es sich nie ändern wird. Ich entwickle es nicht weiter, also plane damit, dass es so bleibt, wie es ist — aber ich lese, was mir Leute schicken, und wenn jemand einen Bug meldet oder etwas vorschlägt, das den Code klarer macht, werde ich nicht so tun, als hätte ich es nicht gesehen.

Die Veröffentlichung bedeutete auch, zu belegen, dass nichts Vertrauliches mitgegangen ist. Im Repository gibt es ein Skript, das jede getrackte Datei nach Zugangsdaten, privaten Schlüsseln, Provider-Tokens und Produktions-IDs durchsucht — und es läuft als erster Job in der CI, vor den Tests, denn ein geleakter Schlüssel ist in dem Moment in der History, in dem er gepusht wird, und ihn danach zu löschen macht das nicht rückgängig.

Das ist drin:

  • Neun Agenten — Strategie, Persona, Content, Performance Intelligence, Competitive Intelligence, Kampagnenplanung, Client Success und zwei weitere. Jeder ist eine Subklasse eines Basis-Agenten und teilt sich eine Prompt-Struktur, eine Tool-Registry und progressiven Kontext.
  • Zwei Postgres-Schemas. Die Daten einzelner Unternehmen liegen in einem, die Agenturdaten im anderen, mit Row Level Security als Isolationsgrenze.
  • Zehn Sprachen, wobei die Sprachkennung über einen Header an die API wandert, damit die generierten Metadaten zur Sprache der Oberfläche passen.
  • Ein Demo-Modus, der keinen API-Key braucht. Die Agenten liefern klar gekennzeichnete vorgefertigte Ausgaben zurück, statt ein Modell aufzurufen — so kannst du die ganze Anwendung durchklicken, ohne ein Provider-Konto zu haben.

Wenn du deine eigene Version davon bauen willst

Das ist die Nutzung, die ich am liebsten sehen würde, also sage ich es lieber klar, statt es nur anzudeuten.

Wenn du eigentlich dein eigenes Marketing-Agenten-System willst — deine eigenen Agenten, deine eigenen Prompts, dein eigenes Schema, dein eigenes Produkt obendrauf —, dann forke das Repository und bau es. Dafür ist die MIT-Lizenz da, und das ist die beabsichtigte Nutzung, kein Schlupfloch. Es gibt keine Attributionspflicht über das Behalten der Lizenzdatei hinaus, und du musst mich nicht fragen. Nimm die Teile, die nützlich sind, wirf die weg, die es nicht sind, und ändere alles, womit du nicht einverstanden bist.

Ich würde lieber ein Dutzend verschiedene Versionen davon sehen als eine einzige, die nur ich je angefasst habe.

Warum v1 veröffentlichen, statt sie für mich zu behalten

Ich habe es zehn Monate lang liegen lassen, weil ich annahm, niemand wolle eine v1, an der ich längst nicht mehr arbeite. Diese Annahme war falsch — und der Grund, warum sie falsch war, ist mehr wert als das Repository selbst.

Die zweite Version geht in eine andere Richtung. Was ich jetzt baue, ähnelt dieser Codebasis kaum, und es wird kein Diff dagegen sein. Hätte ich v1 privat gelassen, wäre es langsam zu einem Museumsstück geworden, das niemand besuchen kann.

Der bessere Grund: Eine funktionierende Agenten-Anwendung für mehrere Mandanten ist ein nützlicheres Artefakt als ein Architekturdiagramm. Als ich lernte, half mir nicht die konzeptionelle Erklärung — es half, ein echtes Projekt zu finden und zu lesen, wie jemand anderes es tatsächlich aufgebaut hatte. Genau die Begründung, die in der Erklärung verloren geht, ist die, auf die es am meisten ankommt.

Ich habe vier Dinge in den Architektur-Notizen des Repositories aufgeschrieben, die ich falsch gemacht habe und nicht wieder so machen würde — und alle vier habe ich drin gelassen. Das erste ist ein Security-Bug.

Der Fehler, der ausgeliefert wurde

Eine Tabelle ging raus, ohne dass Row Level Security aktiviert war.

public.notification_push_deliveries wurde ohne die Zeile angelegt, die RLS aktiviert, und den Rollen anonymous und authenticated mit vollen Rechten zugewiesen, einschließlich TRUNCATE, ohne dass überhaupt Policies definiert waren. Die Tabelle liegt in dem Schema, das die Data API offenlegt, und der anonyme Key wird im Browser-Bundle mitgeliefert. In diesem Zeitraum konnte also jeder, der diesen Key besaß, die Tabelle lesen oder verändern. Sie enthält Push-Geräte-IDs, was daraus ein echtes Datenexpositions-Problem machte und kein theoretisches.

Ich habe es bei einem Security-Review gefunden. Nicht in einem Bug-Report und nicht beim Testen, denn die Anwendung lief die ganze Zeit einwandfrei. Nichts wirft einen Fehler, wenn bei einer Tabelle RLS fehlt. Die Queries laufen durch, das Feature funktioniert, und die Berechtigung, die es nicht geben dürfte, ist einfach da — unsichtbar, bis jemand hinschaut.

Es ist inzwischen geschlossen. RLS ist auf der Tabelle aktiviert, ohne Policies, und den Browser-Rollen wurden die Grants entzogen, sodass die Anfrage, die früher Zeilen zurückgegeben hätte, nun als Berechtigungsfehler zurückkommt. Ich beschreibe das bewusst im Präteritum: So sah der Code aus, nicht so sieht er heute aus.

Und genau da will ich präzise sein: Das war keine Design-Entscheidung, die ich richtig getroffen habe. Es war eine Regression, die ich falsch gebaut und erst danach gefunden habe.

Also habe ich aufgehört, meinem Gedächtnis zu vertrauen

Was ich als Nächstes getan habe, ist das Einzige an dieser Geschichte, das ich guten Gewissens jemand anderem in die Hand drücken würde.

Ich habe einen Test geschrieben, der keine bestimmte Tabelle benennt. Er geht das Schema durch und prüft die Regel: Jede Tabelle in einem exponierten Schema hat Row Level Security aktiviert, und keine materialisierte View im Mandantenkontext ist für eine Browser-Rolle lesbar. Wenn ich nächsten Monat eine Tabelle hinzufüge und es vergesse, schlägt der Test fehl — und er schlägt bei dem an, was ich vergessen habe, nicht bei einer Liste, die ich geschrieben habe, als ich noch daran dachte.

Zwei ehrliche Einschränkungen, die ich lieber selbst einräume, als dass ein Leser sie später entdeckt:

  • Die Materialized-View-Hälfte dieser Prüfung deckt das public-Schema ab, nicht das agency-Schema. Die Agenturseite hat ihre eigene Exposition, die ich nicht geschlossen habe.
  • Der Test überspringt sich selbst, wenn er keine Datenbank erreicht. Ein übersprungener Test ist grün — eine kaputte Umgebung kann also eine verletzte Invariante verstecken.

Wenn ich aus alldem eine Gewohnheit mitgenommen habe, dann diese: Wenn ich einen Bug dieser Art finde, schreibe ich den Test, der ihn gefangen hätte — und ich schreibe ihn gegen die Regel statt gegen das Objekt. Ein Test, der die drei Objekte benennt, an die ich mich erinnert habe, schützt nur diese drei. Ein Test, der die Regel prüft, schützt das, was ich nächste Woche hinzufüge und dann vergesse.

Die zweite Falle

Es gibt eine zweite, die ich eher durch Glück als durch Design richtig gemacht habe — und sie ist die, die ich in Gesprächen mit Anbietern immer wieder höre.

Ein clientseitiger Filter ist keine Grenze. Wenn deine Anwendung Zeilen im Browser nach Organisation filtert — .eq('org_id', ...) und Konsorten —, dann läuft die Isolation auf der Maschine des Nutzers, und das heißt, sie lässt sich mit den Developer Tools entfernen. Alles, was sich entfernen lässt, ist eine Anzeigeeinstellung, keine Sicherheitskontrolle. Zeilensichtbarkeit muss entweder in der Datenbank erzwungen werden oder hinter einem Server-Endpunkt, den der Aufrufer nicht umgehen kann.

Materialisierte Views sind der unangenehme Verwandte dieses Problems. Sie können überhaupt kein Row Level Security haben. Ein SELECT-Grant auf eine materialisierte View liefert die Zeilen aller Mandanten zurück, und in einer Migration sieht er genauso aus wie derselbe Grant auf eine Tabelle, wo Policies ihn trotzdem einschränken würden. Genau deshalb entzieht das Repository Browser-Rollen-Grants auf alles, was im Mandantenkontext materialisiert wird.

Die eine, die ich nicht zu Ende gebracht habe

Row Level Security kann TRUNCATE nicht einschränken. Es ist ein Privileg auf Tabellenebene, nicht auf Zeilenebene, also greifen Policies dafür nicht. Die Baseline-Grants des Repositories geben den Browser-Rollen ALL auf einer großen Zahl von Tabellen — was TRUNCATE einschließt — und danach schränkt nichts das ein. Die Materialized-View-Grants sind entzogen. Diese nicht. Es gibt also Tabellen, in denen eine Browser-Rolle ein Privileg hält, das außerhalb der Grenze liegt, die ich gerade in diesem Abschnitt beschrieben habe.

In der Praxis ist das eher latent als offen: Die Data API hat kein TRUNCATE-Verb, die Browser-Rollen können sich nicht direkt mit der Datenbank verbinden, und ein normales Deployment legt den Datenbank-Port nicht offen. Es ist ein Hygieneproblem. Aber es hat dieselbe Form wie der Bug am Anfang dieses Beitrags — ein Privileg, das seine Begründung stillschweigend überlebt — und ich zeige lieber darauf, als dass ein Leser es findet und sich fragt, ob ich es wusste. Es braucht einen Revoke über beide Schemas hinweg und einen Test, der prüft, dass keine Browser-Rolle ein Privileg hält, das RLS umgeht.

Was ich nicht wieder so machen würde

Zwei parallele Schemas haben jede Menge DDL dupliziert. Agentur- und Einzelunternehmensdaten haben parallele Strukturen statt einer Tabelle mit einer Mandanten-Spalte. Die Trennung ist wirklich sauberer. Die Duplizierung hat in der Pflege mehr gekostet, als die Diskriminator-Spalte gekostet hätte — und ich würde mich nächstes Mal anders entscheiden.

Manuelles Function Calling ist eine Menge Code. Die Agenten-Schleife wickelt Tool-Aufrufe von Hand ab, statt das automatische Function Calling des Anbieters zu nutzen — das hält Streaming und Tool-Ausführung unter Kontrolle der Anwendung. Der Preis: Die Anwendung muss die Function-Result-Parts selbst zusammensetzen, und bei den aktuellen Modellen müssen diese Parts die Call-ID ebenso tragen wie den Funktionsnamen. Lässt du die ID weg, bekommst du keinen Schema-Fehler. Du bekommst etwas, das sich liest, als wäre das Modell unzuverlässig — ein deutlich schlechterer Nachmittag. Wenn Streaming mit Tools inzwischen über eine höhere API-Ebene verfügbar ist, lohnt es sich, diesen Trade-off noch einmal anzuschauen.

Die Migrationskette erreichte 321 Dateien. Ein großer Teil davon hieß fix_, _v2 und remove_, weil ich Korrekturen angehängt habe, statt das zu bearbeiten, was ich schon geschrieben hatte. Der wahre Zustand des Schemas war nur erkennbar, wenn man die History von Anfang an abspielte. Die Beiträge, die ich damals schrieb, waren selbstsicherer über diese Phase, als der Code verdiente — im November 2025 schrieb ich, dreiunddreißig Migrationen hätten die Multi-Tenancy „endlich gelöst", und danach habe ich monatelang weiter korrigierende Migrationen geschrieben.

Das Frontend vertraut den Shapes der API, ohne sie zu validieren. Auf dem Weg hinein wird sorgfältig validiert — das Backend parst jeden Request mit Pydantic —, auf dem Weg hinaus gar nicht. Der Browser nimmt die Antwort und glaubt ihr. Ein geteiltes Schema würde die Abweichung abfangen, wenn ein Feld seine Form ändert, statt sie als leeren Bildschirm zu entdecken. Zusammen mit den drei oben sind das die vier Dinge, von denen die Architektur-Notizen sagen, ich würde sie anders machen.

Das veröffentlichte Repository baut die Migrationskette als zwanzig geschichtete Migrationen neu auf, nach Anliegen gruppiert — erst Tabellen, dann Funktionen nach Domäne gruppiert, Views, Indizes, Trigger, Policies, Grants, geplante Jobs und zum Schluss ein Hardening-Durchgang. Das stimmt ungefähr und nicht exakt, und es lohnt sich, ehrlich zu sagen, wo es hakt: Eine der zwanzig ist ein offen zugegebener Sammelplatz für Funktionen, die sich nicht einordnen ließen, und die Tabellen-Ebene ist nicht so aufgeteilt, wie ihre Dateinamen nahelegen. Die Datei, die nach dem gemeinsamen Schema benannt ist, legt auch die acht Agentur-Tabellen an, und die Datei, die nach dem Agentur-Schema benannt ist, enthält überhaupt keine Tabellendefinitionen. Die Schichtung ist real; die Bezeichnungen sind es nicht.

Was die Aufteilung richtig macht, ist der Teil, auf den es beim Lesen ankommt: Das Schema ist eine reine Neuorganisation, verifiziert, indem es vorher und nachher gedumpt und bestätigt wurde, dass beide bis auf den Zufalls-Token des Dump-Tools byte-identisch sind.

Diese Verifikation ist der einzige Grund, warum ich überhaupt bereit war, das anzufassen.

Was überlebt hat

Row Level Security als tatsächliche Grenze — nicht als Feature, das man einschaltet. Wenn das Design darum herum gebaut ist, hört Isolation auf, ein Punkt auf der Checkliste zu sein, und wird zu einer Eigenschaft des Systems.

Schreibvorgänge laufen über geroutete Datenbankfunktionen. Der Anwendungscode entscheidet nicht, welches Schema er anfasst. Er ruft eine Funktion auf, die den Organisationstyp prüft und weiterleitet — die Entscheidung liegt an einer Stelle, die ein neuer Code-Pfad nicht vergessen kann.

Die Registrierung ignoriert Client-Eingaben. Der Provisioning-Trigger übernimmt keine vom Client gelieferte Organisations-ID und keine Rolle, denn diese Daten sind vom Nutzer kontrollierbar. Er legt eine frische Organisation und eine Standard-Owner-Rolle an. Kleine Sache, leicht falsch zu machen, teuer, wenn es passiert.

Lazy Client-Initialisierung. Services bauen ihren Provider-Client bei der ersten Nutzung statt beim Import. Das klingt nach einer Stilfrage und ist keine: Mehrere Services werden beim Modul-Import erzeugt, eifrige Konstruktion bedeutete also, dass der Import der Anwendung einen API-Key erforderte, und der Fehler zeigte sich als undurchsichtiger Fehler aus dem SDK des Anbieters, bevor überhaupt etwas starten konnte. Lazy zu sein macht den Demo-Modus möglich — und der lässt einen Fremden das Projekt kostenlos ausprobieren.

Die letzte Entscheidung ist die, mit der ich am glücklichsten bin — und ich habe sie nicht aus dem Grund getroffen, aus dem sie sich als wichtig erwies. Ich habe sie getroffen, um den Absturz zu beenden.

Wenn du den Code nie lesen wirst

Das ist der Abschnitt, den ich mir gewünscht hätte, als ich auf Agenturseite war und solche Software eingekauft habe — deshalb kommt darin kein Code vor.

Das, was es wert ist, mitzunehmen, ist der Unterschied zwischen „wir filtern nach Account" und „die Datenbank kann die Zeilen eines anderen Accounts nicht zurückgeben."

Ich habe den größten Teil meiner Karriere damit verbracht, Plattformen einzukaufen, und die letzten Jahre damit, sie zu bauen — und genau diesen Unterschied habe ich nicht verstanden, bis ich die falsche Variante davon ausgeliefert hatte. Das eine ist eine Regel, die der Browser befolgt. Das andere ist eine Regel, die der Browser nicht brechen kann. Die meisten Tools haben die erste und beschreiben sie in der Sprache der zweiten.

Wenn du eine Plattform bewertest, die die Daten mehrerer Kunden halten soll — Competitive Intelligence, Performance-Daten, Audience-Definitionen, was auch immer —, dann ist die Frage nicht „ist es sicher". Da sagt jeder ja. Die Frage ist: Wo wird die Trennung erzwungen, und was passiert, wenn ein Entwickler den Filter entfernt?

Es gibt zwei gute Antworten. Sie wird in der Datenbank durch Policies erzwungen, die an die Identität des angemeldeten Nutzers gebunden sind, oder sie wird hinter einem Server-Endpunkt erzwungen, den der Browser nicht umgehen kann. Jede Antwort, in der der Browser vorkommt, ist ein Nein.

Gute Antworten klingen so: Die Policy liegt auf der Tabelle und ist an die Session des Nutzers gebunden; der Browser fragt diese Tabelle nie direkt ab; hier ist der Test, der das beweist. Weniger nützliche Antworten klingen so: Unsere Anwendung filtert nach Account; die Daten sind verschlüsselt; wir sind SOC-2-konform. Das kann alles stimmen, und keine dieser Antworten beantwortet die Frage.

Es ist eine Zeile, und es passt in ein Vendor-Security-Review — genau da würde ich es auch platzieren. Die Antwort sagt dir, ob Multi-Tenancy von Anfang an mitgedacht oder nachträglich eingebaut wurde.

Häufige Fragen

Warum veröffentlichen, statt es in einem privaten Repository liegen zu lassen?

Weil ein öffentliches Artefakt überprüfbar ist und ein privates nicht. Die früheren Beiträge, die ich über den Bau geschrieben habe, haben Behauptungen aufgestellt; ein Repository mit den Migrationen, den Policies und den Tests ist etwas, das ein Leser nachprüfen kann — auch die Teile, die ich falsch gemacht habe. Die Architektur-Notizen haben einen Abschnitt namens „Trade-offs, und was ich anders machen würde", und er ist der Grund, warum das Repository in dieser Form existiert.

Wird die zweite Version Open Source?

Nein. Sie wird privat entwickelt, und die Codebasis wird dieser hier voraussichtlich nicht ähneln. Das sage ich lieber klar, als es offenzulassen und dann Leute klonen zu lassen, die eine Roadmap erwarten.

Kann ich das in Produktion einsetzen?

Ich würde es nicht. Es ist eine Referenz, kein Produkt. Die Demo-Zugangsdaten in den Seed-Daten sind nur für die lokale Nutzung gedacht, es gibt keine Support-Zusage, und niemand fixt es gegen künftige Dependency-Änderungen. Es ist eine gute Sache zum Lesen und zum Abschauen — und eine schlechte, um ein Unternehmen darauf zu betreiben.

Brauche ich einen KI-Key, um es auszuprobieren?

Nein. Es startet im Demo-Modus, in dem die Agenten klar gekennzeichnete vorgefertigte Ausgaben liefern, statt ein Modell aufzurufen. Du kannst die ganze Anwendung durchklicken — jeden Agenten, die Client-Flows der Agentur, den Sprachumschalter — ohne Key und ohne etwas auszugeben. Der Wechsel zu echten Modellaufrufen ist eine Einstellung und ein Key. Der erste Durchlauf hat bei mir mehr als zehn Minuten gedauert: Es braucht laufendes Docker, Node, Python, Poetry und die Supabase CLI, und der langsame Teil ist ein großer Download.


Ich habe viel über den Bau geschrieben, während ich ihn gebaut habe, und die zwei Beiträge, mit denen ich anfangen würde, sind warum ich Multi-Tenancy an Tag zwei gebaut habe und was passierte, als ich sie an Tag siebenundsechzig neu gebaut habe. Wenn dir nur einer davon die Zeit wert ist, dann der zweite — das ist der, in dem sich die Architektur als falsch erwies.

Der Code liegt auf github.com/chandlernguyen/stratum-oss, und der Test, der den Fehler absichert, den ich ausgeliefert habe, steht in tests/automated/test_rls_coverage.py.

Wenn du ein System für mehrere Mandanten ausgeliefert hast und eine dritte stille Falle gefunden hast, die ich nicht erwähnt habe, würde ich wirklich gern davon hören — genau die sind es wert, gesammelt zu werden.

Feedback, Anregungen und was mit v1 passiert

Ich hätte es lieber nicht als Einbahnstraße, deshalb hier die ehrliche Version dessen, was du erwarten kannst.

Worüber ich mich freue: Bug-Reports, wenn im Repository schlicht etwas falsch ist. Anregungen zu Stellen, die klarer, einfacher oder mit weniger Code lösbar wären. Hinweise von allen, die versucht haben, es zum Laufen zu bringen, und dabei auf etwas gestoßen sind, das das README nicht abdeckt. Pull Requests, wenn du ein echtes Problem findest und es beheben willst. Und wenn du es forkst und etwas Eigenes baust, würde ich gern wissen, was du geändert hast und warum — das ist das interessanteste Feedback überhaupt, weil du die Entscheidungen wirklich treffen musstest.

Was ich versprechen kann: nicht viel, und das sage ich lieber, als etwas anderes anzudeuten. Das ist kein aktiv weiterentwickeltes Projekt, ich betreibe keinen Support-Schalter, und ich kann keine Antwortzeit zusagen. Manche Anregungen werde ich umsetzen. Manche werde ich lesen, ihnen zustimmen und nie dazu kommen. Das ist die realistische Version eines Nebenprojekts, das schon einen Nachfolger hat.

Was mit v1 passiert: es bleibt so veröffentlicht, wie es ist. Ich entwickle es nicht weiter, also plane nicht mit neuen Releases. Aber ich werde nicht so tun, als wäre es versiegelt — der Code ist öffentlich, die Lizenz erlaubt dir, ihn in jede Richtung zu entwickeln, und wenn etwas kaputt oder wirklich unklar ist, gibt es keinen guten Grund, es aus Prinzip so zu lassen.

Der einfachste Weg, mich zu erreichen, ist ein Issue im Repository, oder eine E-Mail, wenn du das lieber nicht öffentlich machen willst.

Das war's erst mal von mir.

Viele Grüße, Chandler