Ga naar inhoud

Chatbruggen

Een chatbrug maakt van een extern chatproduct — vandaag Slack — een eersteklas manier om een Control Center-werkruimte binnen te komen. Noem de bot en een agent wordt wakker; antwoord in de Slack-thread en het gesprek gaat verder; typ een slash-commando en er wordt een ticket aangemaakt. Daaronder dezelfde ruimtes, transcripten, tickets en attributie die de desktopapp gebruikt. Chat is een oppervlak op de werkruimte, geen kopie ervan.

Slack is de enige provider die wordt meegeleverd. Nergens in het product zit een implementatie van Discord, Telegram, Teams of Matrix. De functie is bewust providerblind geschreven — een tweede product toevoegen is één enumwaarde, één adapter en één plugin — maar vandaag betekent “chat” Slack.

Twee oppervlakken dekken het geheel en ze horen bij verschillende mensen. Een werkruimte aan Slack koppelen en de bot aanpassen zijn beheerdersacties onder Instellingen → Werkruimte → Algemeen. Je eigen chataccount aan je Control Center-identiteit koppelen is een ledenactie onder Instellingen → Werkruimte → Profiel en identiteit.

Drie beslissingen bepalen de hele functie: welke richting de verbinding op gaat, wat een bericht dat van buitenaf binnenkomt autoriseert en waar de productspecifieke kennis mag wonen.

De brug luistert nooit naar de provider. Slack wordt bereikt via Socket Mode: de server vraagt Slack om een socket-URL en maakt zelf verbinding naar buiten, en ontvangt daarna vermeldingen, DM’s en slash-commando’s over die verbinding.

Dat gevolg is juist de bedoeling. Een laptop achter NAT, een thuisserver zonder domein, een machine die tussen netwerken wisselt — ze kunnen allemaal de brug draaien zonder inkomende poort, zonder tunnel, zonder TLS-certificaat en zonder openbare URL die je moet bijwerken wanneer hij verandert. Er is geen webhook-endpoint om te beveiligen, want er is geen webhook-endpoint. Wanneer Control Center de app-definitie samenstelt, dwingt het Socket Mode af en haalt het een eventuele request-URL eruit, want een app die voor beide is geconfigureerd, is een app die half werkt.

De prijs is een verbinding die in leven moet blijven, en dat is de taak van de server: opnieuw verbinden gaat met exponentiële backoff en jitter, afgetopt op 30 seconden, een door de provider gestarte socketrotatie maakt opnieuw verbinding zonder die backoff op te voeren en een geweigerd inloggegeven is definitief — een ingetrokken token voor altijd blijven proberen levert alleen maar een snelheidslimiet op, dus de brug stopt en wacht op nieuwe tokens, terwijl de foutmelding van de provider zelf woordelijk in de instellingen wordt gemeld.

Een werkruimte zonder chat-app kost helemaal niets: geen socket, geen timers, geen verkeer. En het geheel start nadat de server zijn gereedmelding heeft gegeven, want een chat-app die traag antwoordt mag het opstarten nooit vertragen.

Lidmaatschap autoriseert, niet het bezit van een token

Sectie getiteld “Lidmaatschap autoriseert, niet het bezit van een token”

Wie de app heeft geïnstalleerd is niet wie het bericht stuurt. Daarom wordt elk inkomend event omgezet naar een Principal voordat er iets gebeurt, en bij twijfel weigert het systeem met een uitleg:

  • Een niet-gekoppeld chataccount krijgt in de Slack-thread waarin het schreef te horen hoe het moet koppelen.
  • Een gekoppeld account waarvan de gebruiker geen lid is van deze werkruimte krijgt te horen dat hij een beheerder om een uitnodiging moet vragen.
  • Een lid met een alleen-lezen rol (viewer, guest) hoort dat zijn rol geen werk kan starten.

Geen van deze gevallen gaat verder. Een chatbericht wordt nooit toegeschreven aan iemand die het niet heeft gestuurd — dat houdt het auditlogboek, de snelheidslimieten per principal en de melders van tickets eerlijk. Zie Multiplayer — identiteit, lidmaatschap en aanwezigheid voor het lidmaatschapmodel waarop dit leunt.

Koppelen is identiteitsherleiding, geen toegangsverlening. Het lidmaatschap wordt bij elk event opnieuw gecontroleerd, dus een koppeling van iemand die inmiddels is verwijderd leidt tot een weigering en niet tot zijn oude rechten. Er zijn twee manieren om een koppeling tot stand te brengen: een kortlevende code voor eenmalig gebruik die het lid in de chat typt, of een automatische match op het geverifieerde e-mailadres van de provider — en die e-mailroute vereist nog steeds dat de gematchte gebruiker al lid is, dus een vreemde wiens adres toevallig op de server bestaat koppelt aan niets.

De codes worden bewust in het geheugen gehouden, met een levensduur van 15 minuten. Ze opslaan zou een blijvende rij in de vorm van een inloggegeven opleveren voor iets dat hoort te verdampen, en dat een herstart van de server elke openstaande code ongeldig maakt is het juiste gedrag, geen regressie — het lid drukt gewoon weer op “koppelen”.

Een weigering wordt in de Slack-thread die het bericht opende beantwoord, als een echt antwoord. Een antwoord in een thread voorziet het bericht waarop het reageert van een badge, bereikt precies de persoon die het nodig heeft en houdt “je bent niet gekoppeld” toch buiten het kanaal zelf. Een bericht plaatsen is bovendien de enige capaciteit die elke provider heeft, dus de uitleg hangt nooit af van een functie die de app misschien niet heeft.

Een brug die beide kanten op spiegelt raakt in een lus, tenzij iets de twee richtingen uit elkaar houdt. Elk bericht dat de brug binnenbrengt krijgt een stempel metadata['chat'] — provider, team, gesprek, thread, bericht en afzender. De uitgaande spiegel slaat elk bericht met dat stempel over.

Die ene regel maakt tweerichtingsspiegeling veilig: een menselijk bericht dat in Control Center wordt getypt gaat naar de gekoppelde thread (met de naam van de auteur ervoor), terwijl een bericht dat uit de chat kwam stopt waar het binnenkwam.

Wat naar buiten gaat is bewust smal. Het antwoord van de agent wordt doorgegeven als gewone chattekst. Een korte gedachte kan op de regel Thinking… van de kaart verschijnen. Tool-uitvoer is een muur van diffs en blijft in Control Center, en daar wijst ook een afgekapt antwoord naartoe.

Een kaart rapporteert het werk; de tekst blijft het bericht

Sectie getiteld “Een kaart rapporteert het werk; de tekst blijft het bericht”

Waar de provider ze kan weergeven draagt een beurt één taakkaart, die ter plaatse wordt bewerkt zolang de beurt loopt en boven het antwoord staat dat hij toelicht.

De kaart is één gegroepeerd plan, geen tot moes gemaakte alinea. Slack plakt details en output van dezelfde taak aan elkaar, dus de details van een regel worden één keer verstuurd (de gedachte op Thinking…) en output wordt nooit gestreamd. De voorbereiding is één regel waarvan de titel wordt vervangen (Working on it…, Cloning …). Redeneren voegt een regel Thinking… toe. Elke tool krijgt zijn eigen regel (Read lib/main.dart). Het antwoord is gewone tekst onder de kaart — de planweergave van Slack leest die uit een latere markdown-append, niet uit een brok dat tussen de taken is gemengd. Niets anders: geen tool-uitvoer en slechts één tool-argument uit een korte allowlist, want de argumenten van een onbekende tool kunnen een complete bestandsinhoud zijn en een regel is maar één regel.

De kaart dekt ook het wachten vóór de beurt. Een eerste vermelding maakt het gesprek aan, en dat aanmaken kloont elke repo waarin de agent gaat werken — wat bij een echte repository minuten duurt waarin de agent nog niets is gevraagd en de thread eruitziet alsof de bot het verzoek heeft genegeerd. Daarom opent de kaart op het moment dat de vermelding wordt geaccepteerd (Working on it…) en werkt die voorbereidingsregel daarna bij met dezelfde woorden die de desktop toont — Cloning acme/widgets…, Setting up Reviewer…, Starting the agent… —, dan een regel Thinking… zodra de agent begint te redeneren, en daarna een regel per tool. Eén kaart, één bericht, van “ik heb je gehoord” tot en met het antwoord als gewone tekst eronder. Voorbereiding wordt alleen gemeld waar iemand in de chat erop wacht: een gesprek dat in de app wordt klaargezet, of een achtergebleven gesprek dat bij het opstarten van de server wordt hervat, wordt ingericht zonder enige kaart.

Hij draagt het enige dat de chat de lezer niet kan geven: een View in Control Center-link naar het gesprek achter het antwoord. Chatproducten accepteren in een link alleen http(s) en niets anders, dus de knop wijst naar een kleine pagina die deze server aanbiedt en die de browser doorstuurt naar de control-center://-link van de desktopapp. Die pagina leest niets en herleidt niets — de id’s in zijn URL worden in een deeplink herhaald en nergens anders — en een server zonder bereikbare openbare URL levert simpelweg een kaart zonder knop op in plaats van een kapotte.

Kaarten zijn een capaciteit als elke andere. Een provider zonder kaarten gedraagt zich precies zoals voorheen, alleen tekst, en een provider die er op het moment van de aanroep een weigert houdt zijn stream: het antwoord gaat altijd vóór op de kaart die het beschrijft. Staat streamen helemaal uit, dan lift de voltooide kaart mee op het ene geplaatste bericht. Een aangemaakt ticket krijgt dezelfde behandeling — de bevestiging van /cc ticket is een voltooide kaart waarvan het bestaansrecht de link naar het zojuist aangemaakte ticket is.

Capaciteiten worden onderhandeld en daarna onthouden

Sectie getiteld “Capaciteiten worden onderhandeld en daarna onthouden”

Chatproducten verschillen in wat ze kunnen weergeven, dus de brug is geschreven tegen het rijkste oppervlak — een antwoord dat live meegroeit in een thread, een tijdelijke status “is thinking…”, een thread met een titel, een vluchtig antwoord op een slash-commando — en schaalt telkens één capaciteit terug. Een provider zonder dit alles werkt nog steeds; antwoorden komen dan gewoon binnen als één bericht.

Een capaciteit adverteren is die nog niet beloven. De streamende antwoorden van Slack vereisen een functie uit een betaald abonnement en Slack weigert pas op het moment van de aanroep. Een weigering die als permanent is gemarkeerd wordt voor de hele levensduur van de verbinding onthouden: de brug vraagt het niet meer en plaatst vanaf dat moment hele antwoorden, en de instellingen zeggen dat ronduit, want “mijn antwoorden streamen niet meer” moet te beantwoorden zijn zonder een logboek te lezen. Taakkaarten worden op dezelfde manier geschrapt als Slack er een weigert.

Elke limiet hoort bij de provider en niet bij het doorgeefluik: het maximum voor berichten, het maximum voor streamchunks en of een stream een thread-anker nodig heeft worden allemaal door de adapter gedeclareerd, en een lang antwoord wordt gesplitst op een alinea-, regel- of woordgrens en niet midden in een woord.

Alles wat de vorm van een product heeft, woont achter een poort. De generieke kern kent markdown, chatgesprekken, chatthreads en leden; hij weet niets van Slack-enveloppen, mrkdwn of gateway-intents. Drie onderdelen maken dat mogelijk:

  • De adapter normaliseert één product: zijn transport, zijn API-aanroepen, zijn tekstcodec en zijn eigenaardigheden in enveloppen. Die van Slack zijn leerzaam — een kanaalvermelding komt twee keer binnen (als app_mention en als message) en de meeste berichtsubtypen zijn franje in plaats van nieuwe verzoeken. Beide worden in de adapter opgelost, zodat de kern nooit leert dat Slack dubbel bezorgt.
  • De descriptor is de publieke vorm van de provider: welke inloggegevens hij nodig heeft, wat hun formaten zijn, welke URL’s een gebruiker helpen ze te vinden, wat hij kan en of zijn app van hieruit kan worden aangemaakt of bewerkt. De instellingenkaart, het verbindingsdialoogvenster en de argumentvalidatie worden er allemaal gegenereerd uit — de client stuurt een provider terug plus een map met veld-id’s als sleutels.
  • De plugin is de samenstelling aan de serverkant: hoe inloggegevens een geverifieerde verbinding worden, hoe een adapter wordt gebouwd en hoe de app aan de providerkant wordt beheerd.

Een product toevoegen is dus één enumwaarde, één adapter en één plugin. Geen nieuwe RPC-operatie, geen schemawijziging, geen clientwijziging — en de client blijft eerlijk, want een veld waarvan hij nog nooit heeft gehoord wordt nog steeds weergegeven met het label dat de server heeft aangeleverd.

Twee details voorkomen dat de abstractie naar boven lekt. De stappen voor app-beheer waarvoor een provider geen API heeft (een token genereren, de app installeren, opnieuw installeren na een rechtenwijziging) worden teruggegeven als benoemde stappen met hun eigen links, zodat het scherm voor het afronden van de installatie ze toont zonder te weten welke provider het is. En elke instructie die de brug schrijft noemt de provider en het commando dat de app daadwerkelijk gebruikt, zodat een hernoemd commando of een tweede provider correct leest zonder nieuwe tekst.

De chattokens van een werkruimte staan in de eigen map van die werkruimte, naast zijn database — als platte JSON in <dataDir>/<workspaceId>/chat_credentials/<provider>.json, niet in de database en bewust niet in de sleutelhanger van het besturingssysteem. Twee gevolgen raak je later makkelijk kwijt: het verwijderen van een werkruimte ontkoppelt de map en de tokens verdwijnen mee, en het exporteren of back-uppen van een werkruimte kopieert workspace.db, dus een werkruimte die je aan iemand anders geeft, bevat geen levende bottokens. Het bestand erft de vertrouwensgrens van het bestandssysteem van de serverhost — alles met leestoegang tot de gegevensmap kan een bottoken lezen, dezelfde grens waarbinnen de databases zich bevinden.

Eén bestand per provider, atomair weggeschreven en alleen toegankelijk voor de eigenaar: een ingetrokken app verwijder je door één bestand te ontkoppelen, en een beschadigd bestand kost één provider in plaats van allemaal. Inloggegevens komen naar binnen via de verbindingsoperatie en gaan er nooit meer uit — statusuitlezingen antwoorden alleen met verbindingsmetadata.

  • Structurele app-instellingen. Het transport, de scopes en de gebeurtenisabonnementen worden afgedwongen, niet blootgesteld. Een app die je vanaf een instellingenscherm tot stilte kunt configureren is een supportticket, geen functie.
  • Gesprekken én slash-commando’s. Het Slack-oppervlak bestaat uit berichten, antwoorden in threads en slash-commando’s.
  • Een tweede bron van waarheid. Chat is geen plek waar werk wordt opgeslagen. De Slack-thread is een ruimte, het antwoord is een transcript, het ticket is een ticket — allemaal in de werkruimte, bereikbaar vanaf elke client.