Lewati ke konten

Jembatan chat

Jembatan chat menjadikan produk chat eksternal — untuk saat ini Slack — cara kelas satu untuk masuk ke ruang kerja Control Center. Sebut botnya dan seorang agen terbangun; balas di thread Slack dan percakapan berlanjut; ketik perintah slash dan sebuah tiket pun dibuat. Di baliknya, space, transkrip, tiket, dan atribusi yang sama seperti yang dipakai aplikasi desktop. Chat adalah permukaan pada ruang kerja, bukan salinannya.

Slack adalah satu-satunya penyedia yang tersedia. Tidak ada implementasi Discord, Telegram, Teams, atau Matrix di mana pun dalam produk ini. Fitur ini sengaja ditulis tanpa memandang penyedia — menambahkan produk kedua cukup satu nilai enum, satu adapter, dan satu plugin — tetapi hari ini “chat” berarti Slack.

Ada dua permukaan yang mencakupnya dan keduanya milik orang yang berbeda. Menghubungkan ruang kerja ke Slack dan menyesuaikan bot adalah tindakan admin di bawah Pengaturan → Ruang kerja → Umum. Menautkan akun chat Anda sendiri ke identitas Control Center Anda adalah tindakan anggota di bawah Pengaturan → Ruang kerja → Profil & identitas.

Tiga keputusan membentuk seluruh fitur ini: ke arah mana koneksinya berjalan, apa yang mengotorisasi pesan yang datang dari luar, dan di mana pengetahuan khusus produk boleh berada.

Jembatan ini tidak pernah mendengarkan penyedianya. Slack dijangkau melalui Socket Mode: server meminta URL socket kepada Slack lalu menghubungi keluar, lalu menerima sebutan, DM, dan perintah slash melalui koneksi itu.

Konsekuensinya justru intinya. Laptop di belakang NAT, server rumahan tanpa domain, mesin yang berpindah antarajaringan — semuanya dapat menjalankan jembatan tanpa port masuk, tanpa tunnel, tanpa sertifikat TLS, dan tanpa URL publik yang harus diganti saat berubah. Tidak ada endpoint webhook yang perlu diamankan karena memang tidak ada endpoint webhook. Saat Control Center menyusun definisi aplikasi, ia menegaskan Socket Mode dan membuang URL permintaan apa pun, karena aplikasi yang dikonfigurasi untuk keduanya adalah aplikasi yang hanya setengah berfungsi.

Biayanya adalah koneksi yang harus dijaga tetap hidup, dan itu tugas server: koneksi ulang memakai backoff eksponensial dengan jitter yang dibatasi maksimum 30 detik, rotasi socket yang diprakarsai penyedia akan menyambung ulang tanpa menaikkan backoff tersebut, dan kredensial yang ditolak bersifat final — mencoba ulang token yang sudah dicabut selamanya hanya akan mendatangkan pembatasan laju, jadi jembatan berhenti dan menunggu token baru sambil melaporkan galat dari penyedia itu sendiri apa adanya di pengaturan.

Ruang kerja tanpa aplikasi chat tidak memakan biaya sama sekali: tidak ada socket, tidak ada timer, tidak ada lalu lintas. Dan semuanya baru dimulai setelah banner siap milik server, karena aplikasi chat yang lambat menjawab tidak boleh menunda boot.

Keanggotaan yang mengotorisasi, bukan kepemilikan token

Bagian berjudul “Keanggotaan yang mengotorisasi, bukan kepemilikan token”

Orang yang memasang aplikasi bukanlah orang yang mengirim pesan. Jadi setiap peristiwa masuk diselesaikan menjadi Principal sebelum apa pun terjadi, dan kegagalannya berakhir dengan penolakan disertai penjelasan:

  • Akun chat yang belum ditautkan diberi tahu cara menautkannya, di thread Slack tempat ia menulis.
  • Akun tertaut yang penggunanya bukan anggota ruang kerja ini diberi tahu agar meminta undangan kepada admin.
  • Anggota yang perannya hanya baca (viewer, guest) diberi tahu bahwa perannya tidak dapat memulai pekerjaan.

Tidak satu pun dari kasus itu dilanjutkan. Pesan chat tidak pernah diatribusikan kepada orang yang tidak mengirimnya — inilah yang menjaga jejak audit, pembatasan laju per Principal, dan pelapor tiket tetap jujur. Lihat Multiplayer — identitas, keanggotaan dan kehadiran untuk model keanggotaan yang menjadi landasannya.

Menautkan adalah penyelesaian identitas, bukan pemberian akses. Keanggotaan diperiksa ulang pada setiap peristiwa, sehingga tautan milik seseorang yang sejak itu sudah dihapus menghasilkan penolakan, bukan hak istimewanya yang lama. Ada dua jalur untuk menautkan: kode sekali pakai berumur pendek yang diketik anggota di chat, atau pencocokan otomatis pada email terverifikasi milik penyedia — dan jalur email tetap mensyaratkan pengguna yang cocok sudah menjadi anggota, jadi orang asing yang alamatnya kebetulan ada di server tidak tertaut ke apa pun.

Kode-kode itu sengaja disimpan di memori, dengan masa hidup 15 menit. Menyimpannya secara persisten akan menciptakan baris berumur panjang berbentuk kredensial untuk sesuatu yang memang dimaksudkan menguap, dan mulai ulang server yang membatalkan semua kode yang masih berlaku adalah perilaku yang benar, bukan regresi — anggota tinggal menekan “tautkan” lagi.

Penolakan dijawab di thread Slack tempat pesan itu dibuka, sebagai balasan yang sungguhan. Balasan dalam thread memberi lencana pada pesan yang dijawabnya, menjangkau satu-satunya orang yang membutuhkannya, dan tetap menjauhkan “Anda belum tertaut” dari kanal itu sendiri. Memposting pesan juga satu-satunya kemampuan yang dimiliki semua penyedia, jadi penjelasannya tidak pernah bergantung pada fitur yang mungkin tidak dibawa aplikasi.

Jembatan yang mencerminkan dua arah akan berputar-putar tanpa henti kecuali ada sesuatu yang membedakan kedua arah itu. Setiap pesan yang dibawa masuk jembatan diberi stempel metadata['chat'] — penyedia, tim, percakapan, thread, pesan, dan pengirim. Cermin keluar melewati setiap pesan yang membawa stempel itu.

Aturan tunggal itulah yang membuat pencerminan dua arah aman: pesan manusia yang diketik di Control Center diteruskan keluar ke thread tertaut (diberi awalan nama penulisnya), sedangkan pesan yang datang dari chat berhenti di tempat ia masuk.

Yang menyeberang ke luar sengaja dibatasi. Jawaban agen diteruskan sebagai teks chat biasa. Pemikiran singkat dapat muncul pada baris Thinking… di kartu. Keluaran tool berupa tumpukan diff dan tetap di Control Center, yang juga menjadi tempat balasan yang terpotong mengarah.

Kartu melaporkan pekerjaannya; teksnya tetap pesannya

Bagian berjudul “Kartu melaporkan pekerjaannya; teksnya tetap pesannya”

Di tempat penyedia merendernya, satu giliran membawa satu kartu tugas, yang diedit di tempat selama giliran itu berjalan dan berada di atas jawaban yang dijelaskannya.

Kartu itu satu rencana yang dikelompokkan, bukan paragraf yang diaduk. Slack menyatukan details dan output pada tugas yang sama, jadi detail sebuah baris dikirim satu kali (pemikiran pada Thinking…) dan output tidak pernah dialirkan. Penyiapan adalah satu baris yang judulnya diganti (Working on it…, Cloning …). Penalaran menambahkan baris Thinking…. Setiap tool punya barisnya sendiri (Read lib/main.dart). Jawabannya adalah teks biasa di bawah kartu — tampilan rencana Slack membacanya dari lampiran markdown yang datang belakangan, bukan dari potongan yang dicampur ke dalam tugas. Tidak ada yang lain: tidak ada keluaran tool, dan hanya argumen tool dari daftar izin yang pendek, karena argumen tool yang tidak dikenal bisa berupa seluruh isi berkas sedangkan satu baris hanya satu baris.

Kartu itu juga mencakup masa tunggu sebelum giliran. Sebutan pertama membuat percakapan, dan pembuatannya mengkloning setiap repo yang akan dipakai agen untuk bekerja — yang pada repo sungguhan berarti beberapa menit ketika agen belum dimintai apa pun dan thread tampak seperti bot mengabaikan permintaan. Karena itu kartu terbuka begitu sebutan diterima (Working on it…), lalu baris penyiapan itu diperbarui dengan kata-kata yang sama seperti yang ditampilkan desktop — Cloning acme/widgets…, Setting up Reviewer…, Starting the agent… — lalu baris Thinking… begitu agen mulai bernalar, kemudian satu baris per tool. Satu kartu, satu pesan, dari “saya mendengar Anda” sampai jawaban sebagai teks biasa di bawahnya. Penyiapan hanya dilaporkan bila ada seseorang di chat yang menunggunya: percakapan yang sedang disiapkan di aplikasi, atau percakapan telantar yang dilanjutkan saat server boot, menjalani provisioning tanpa kartu sama sekali.

Kartu itu membawa satu-satunya hal yang tidak dapat diberikan chat kepada pembacanya: tautan View in Control Center ke percakapan di balik balasan. Produk chat hanya menerima http(s) pada tautan dan tidak yang lain, jadi tombolnya menunjuk ke halaman kecil yang dilayani server ini, yang memantulkan peramban ke tautan control-center:// milik aplikasi desktop. Halaman itu tidak membaca apa pun dan tidak menyelesaikan apa pun — id di URL-nya hanya diteruskan ke tautan mendalam dan tidak ke tempat lain — dan server tanpa URL publik yang dapat dijangkau cukup menghasilkan kartu tanpa tombol, bukan kartu yang rusak.

Kartu adalah kemampuan seperti kemampuan lainnya. Penyedia yang tidak memilikinya berperilaku persis seperti sebelumnya, hanya teks, dan penyedia yang menolak sebuah kartu pada saat pemanggilan tetap mempertahankan alirannya: jawaban selalu lebih diutamakan daripada kartu yang menjelaskannya. Saat streaming dimatikan sepenuhnya, kartu yang sudah selesai ikut pada satu-satunya pesan yang diposting. Tiket yang dibuat mendapat perlakuan yang sama — konfirmasi /cc ticket adalah kartu yang sudah selesai yang alasan keberadaannya adalah tautan ke tiket yang baru saja dibuat.

Produk chat berbeda dalam hal yang dapat direndernya, jadi jembatan ditulis untuk permukaan terkaya — balasan yang tumbuh langsung di dalam thread, status sementara “is thinking…”, thread berjudul, balasan sementara untuk perintah slash — dan menurunkan satu kemampuan pada satu waktu. Penyedia yang tidak memiliki satu pun tetap berfungsi; balasan sekadar datang sebagai satu pesan.

Mengiklankan sebuah kemampuan bukan berarti menjanjikannya. Balasan streaming Slack memerlukan fitur paket berbayar dan Slack hanya menolaknya pada saat pemanggilan. Penolakan yang ditandai permanen diingat sepanjang seluruh masa hidup koneksi: jembatan berhenti meminta dan mulai memposting balasan utuh sejak saat itu, dan pengaturan menyatakannya secara gamblang, karena “balasan saya berhenti streaming” harus bisa dijawab tanpa membaca log. Kartu tugas juga dilepaskan dengan cara yang sama jika Slack menolaknya.

Setiap batas dimiliki penyedia, bukan relai: plafon pesan, plafon potongan stream, dan apakah sebuah stream memerlukan jangkar thread semuanya dideklarasikan oleh adapter, dan balasan panjang dipecah pada batas paragraf, baris, atau kata, bukan di tengah kata.

Segala hal yang berbentuk produk berada di balik port. Inti generik mengenal markdown, percakapan chat, thread chat, dan anggota; ia tidak tahu apa-apa tentang envelope Slack, mrkdwn, atau intent gateway. Tiga bagian membuatnya berhasil:

  • Adapter menormalkan satu produk: transportnya, panggilan API-nya, codec teksnya, dan keanehan envelope-nya. Envelope Slack memberi pelajaran — sebutan di kanal datang dua kali (sebagai app_mention dan sebagai message) dan sebagian besar subtipe pesan hanyalah hiasan antarmuka, bukan permintaan baru. Keduanya diselesaikan di adapter, sehingga inti tidak pernah tahu bahwa Slack mengirim dua kali.
  • Deskriptor adalah bentuk publik penyedia: kredensial apa yang dibutuhkannya, bagaimana formatnya, URL mana yang membantu pengguna menemukannya, apa yang dapat dilakukannya, dan apakah aplikasinya dapat dibuat atau diedit dari sini. Kartu pengaturan, dialog koneksi, dan validasi argumen semuanya dihasilkan dari deskriptor itu — klien mengirim balik penyedia plus map dengan kunci berupa id bidang.
  • Plugin adalah komposisi sisi server: bagaimana kredensial menjadi koneksi yang terverifikasi, bagaimana adapter dibangun, dan bagaimana aplikasi di sisi penyedia dikelola.

Jadi menambahkan produk berarti satu nilai enum, satu adapter, dan satu plugin. Tidak ada operasi RPC baru, tidak ada perubahan skema, tidak ada perubahan klien — dan klien tetap jujur, karena bidang yang belum pernah dikenalnya tetap dirender dengan label yang dipasok server.

Dua detail mencegah abstraksi itu merembes ke atas. Langkah pengelolaan aplikasi yang tidak memiliki API pada penyedia (membuat token, memasang aplikasi, memasang ulang setelah perubahan izin) dikembalikan sebagai langkah bernama dengan tautannya sendiri, sehingga layar penyelesaian penyiapan dapat menampilkannya tanpa tahu penyedia mana itu. Dan setiap instruksi yang ditulis jembatan menyebutkan penyedia serta perintah yang benar-benar dipakai aplikasi, sehingga perintah yang diganti nama atau penyedia kedua terbaca dengan benar tanpa string baru.

Token chat sebuah ruang kerja berada di direktori milik ruang kerja itu sendiri, di samping basis datanya — sebagai JSON biasa di <dataDir>/<workspaceId>/chat_credentials/<provider>.json, bukan di dalam basis data dan sengaja tidak di keychain OS. Dua konsekuensinya mudah terlupakan di kemudian hari: menghapus ruang kerja akan melepas direktori itu dan tokennya ikut hilang, dan mengekspor atau mencadangkan ruang kerja menyalin workspace.db, jadi ruang kerja yang diserahkan kepada orang lain tidak membawa token bot yang masih hidup di dalamnya. Berkas itu mewarisi batas kepercayaan sistem berkas host server — apa pun yang punya akses baca ke direktori data dapat membaca token bot, dan itulah batas yang sama dengan yang melingkupi basis data.

Satu berkas per penyedia, ditulis secara atomik dan hanya untuk pemiliknya: aplikasi yang dicabut dihapus dengan melepas satu berkas, dan berkas yang rusak hanya merugikan satu penyedia, bukan semuanya. Kredensial masuk ke dalam melalui operasi koneksi dan tidak pernah keluar lagi — pembacaan status hanya menjawab dengan metadata koneksi.

  • Pengaturan aplikasi yang struktural. Transport, scope, dan langganan peristiwa ditegaskan, bukan dipaparkan. Aplikasi yang bisa dikonfigurasi sampai membisu dari layar pengaturan adalah tiket dukungan, bukan fitur.
  • Percakapan plus perintah slash. Permukaan Slack adalah pesan, balasan thread, dan perintah slash.
  • Sumber kebenaran kedua. Chat bukan tempat pekerjaan disimpan. Thread Slack adalah space, balasannya adalah transkrip, tiketnya adalah tiket — semuanya ada di ruang kerja, dapat dijangkau dari setiap klien.