Merge branch 'main' into perplexity-search

This commit is contained in:
Kesku 2026-01-25 23:58:45 -08:00 committed by GitHub
commit bf6c33a987
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 1912 additions and 81 deletions

View File

@ -11,6 +11,15 @@ Status: unreleased.
- Agents: expand cron tool description with full schema docs. (#1988) Thanks @tomascupr.
- Skills: add missing dependency metadata for GitHub, Notion, Slack, Discord. (#1995) Thanks @jackheuberger.
- Docs: add Render deployment guide. (#1975) Thanks @anurag.
- Docs: add Claude Max API Proxy guide. (#1875) Thanks @atalovesyou.
- Docs: add DigitalOcean deployment guide. (#1870) Thanks @0xJonHoldsCrypto.
- Docs: add Raspberry Pi install guide. (#1871) Thanks @0xJonHoldsCrypto.
- Docs: add GCP Compute Engine deployment guide. (#1848) Thanks @hougangdev.
- Docs: credit both contributors for Control UI refresh. (#1852) Thanks @EnzeD.
- Onboarding: add Venice API key to non-interactive flow. (#1893) Thanks @jonisjongithub.
- Tlon: format thread reply IDs as @ud. (#1837) Thanks @wca4a.
- Gateway: prefer newest session metadata when combining stores. (#1823) Thanks @emanuelst.
- Web UI: keep sub-agent announce replies visible in WebChat. (#1977) Thanks @andrescardonas7.
- CI: increase Node heap size for macOS checks. (#1890) Thanks @realZachi.
- macOS: avoid crash when rendering code blocks by bumping Textual to 0.3.1. (#2033) Thanks @garricn.
- Browser: fall back to URL matching for extension relay target resolution. (#1999) Thanks @jonit-dev.
@ -25,6 +34,9 @@ Status: unreleased.
- Slack: clear ack reaction after streamed replies. (#2044) Thanks @fancyboi999.
- macOS: keep custom SSH usernames in remote target. (#2046) Thanks @algal.
### Fixes
- Web UI: improve WebChat image paste previews and allow image-only sends. (#1925) Thanks @smartprogrammer93.
## 2026.1.24-3
### Fixes
@ -58,7 +70,7 @@ Status: unreleased.
- Telegram: treat DM topics as separate sessions and keep DM history limits stable with thread suffixes. (#1597) Thanks @rohannagpal.
- Telegram: add `channels.telegram.linkPreview` to toggle outbound link previews. (#1700) Thanks @zerone0x. https://docs.clawd.bot/channels/telegram
- Web search: add Brave freshness filter parameter for time-scoped results. (#1688) Thanks @JonUleis. https://docs.clawd.bot/tools/web
- UI: refresh Control UI dashboard design system (typography, colors, spacing). (#1786) Thanks @mousberg.
- UI: refresh Control UI dashboard design system (colors, icons, typography). (#1745, #1786) Thanks @EnzeD, @mousberg.
- Exec approvals: forward approval prompts to chat with `/approve` for all channels (including plugins). (#1621) Thanks @czekaj. https://docs.clawd.bot/tools/exec-approvals https://docs.clawd.bot/tools/slash-commands
- Gateway: expose config.patch in the gateway tool with safe partial updates + restart sentinel. (#1653) Thanks @Glucksberg.
- Diagnostics: add diagnostic flags for targeted debug logs (config + env override). https://docs.clawd.bot/diagnostics/flags

View File

@ -484,7 +484,7 @@ Thanks to all clawtributors:
<a href="https://github.com/vignesh07"><img src="https://avatars.githubusercontent.com/u/1436853?v=4&s=48" width="48" height="48" alt="vignesh07" title="vignesh07"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/TSavo"><img src="https://avatars.githubusercontent.com/u/877990?v=4&s=48" width="48" height="48" alt="TSavo" title="TSavo"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/bradleypriest"><img src="https://avatars.githubusercontent.com/u/167215?v=4&s=48" width="48" height="48" alt="bradleypriest" title="bradleypriest"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/rohannagpal"><img src="https://avatars.githubusercontent.com/u/4009239?v=4&s=48" width="48" height="48" alt="rohannagpal" title="rohannagpal"/></a>
<a href="https://github.com/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></a> <a href="https://github.com/f-trycua"><img src="https://avatars.githubusercontent.com/u/195596869?v=4&s=48" width="48" height="48" alt="f-trycua" title="f-trycua"/></a> <a href="https://github.com/benostein"><img src="https://avatars.githubusercontent.com/u/31802821?v=4&s=48" width="48" height="48" alt="benostein" title="benostein"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/pvoo"><img src="https://avatars.githubusercontent.com/u/20116814?v=4&s=48" width="48" height="48" alt="pvoo" title="pvoo"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/stefangalescu"><img src="https://avatars.githubusercontent.com/u/52995748?v=4&s=48" width="48" height="48" alt="stefangalescu" title="stefangalescu"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a>
<a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/denysvitali"><img src="https://avatars.githubusercontent.com/u/4939519?v=4&s=48" width="48" height="48" alt="denysvitali" title="denysvitali"/></a> <a href="https://github.com/orlyjamie"><img src="https://avatars.githubusercontent.com/u/6668807?v=4&s=48" width="48" height="48" alt="orlyjamie" title="orlyjamie"/></a>
<a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a>
<a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a>
<a href="https://github.com/joshrad-dev"><img src="https://avatars.githubusercontent.com/u/62785552?v=4&s=48" width="48" height="48" alt="joshrad-dev" title="joshrad-dev"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/Takhoffman"><img src="https://avatars.githubusercontent.com/u/781889?v=4&s=48" width="48" height="48" alt="Takhoffman" title="Takhoffman"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/pauloportella"><img src="https://avatars.githubusercontent.com/u/22947229?v=4&s=48" width="48" height="48" alt="pauloportella" title="pauloportella"/></a>
<a href="https://github.com/neooriginal"><img src="https://avatars.githubusercontent.com/u/54811660?v=4&s=48" width="48" height="48" alt="neooriginal" title="neooriginal"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/travisirby"><img src="https://avatars.githubusercontent.com/u/5958376?v=4&s=48" width="48" height="48" alt="travisirby" title="travisirby"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a> <a href="https://github.com/kyleok"><img src="https://avatars.githubusercontent.com/u/58307870?v=4&s=48" width="48" height="48" alt="kyleok" title="kyleok"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/apps/dependabot"><img src="https://avatars.githubusercontent.com/in/29110?v=4&s=48" width="48" height="48" alt="dependabot[bot]" title="dependabot[bot]"/></a>
<a href="https://github.com/John-Rood"><img src="https://avatars.githubusercontent.com/u/62669593?v=4&s=48" width="48" height="48" alt="John-Rood" title="John-Rood"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/uos-status"><img src="https://avatars.githubusercontent.com/u/255712580?v=4&s=48" width="48" height="48" alt="uos-status" title="uos-status"/></a> <a href="https://github.com/gerardward2007"><img src="https://avatars.githubusercontent.com/u/3002155?v=4&s=48" width="48" height="48" alt="gerardward2007" title="gerardward2007"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/JonUleis"><img src="https://avatars.githubusercontent.com/u/7644941?v=4&s=48" width="48" height="48" alt="JonUleis" title="JonUleis"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a>
@ -504,7 +504,7 @@ Thanks to all clawtributors:
<a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/louzhixian"><img src="https://avatars.githubusercontent.com/u/7994361?v=4&s=48" width="48" height="48" alt="louzhixian" title="louzhixian"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Matt%20mini"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Matt mini" title="Matt mini"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a>
<a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/Noctivoro"><img src="https://avatars.githubusercontent.com/u/183974570?v=4&s=48" width="48" height="48" alt="Noctivoro" title="Noctivoro"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/ptn1411"><img src="https://avatars.githubusercontent.com/u/57529765?v=4&s=48" width="48" height="48" alt="ptn1411" title="ptn1411"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/senoldogann"><img src="https://avatars.githubusercontent.com/u/45736551?v=4&s=48" width="48" height="48" alt="senoldogann" title="senoldogann"/></a>
<a href="https://github.com/Seredeep"><img src="https://avatars.githubusercontent.com/u/22802816?v=4&s=48" width="48" height="48" alt="Seredeep" title="Seredeep"/></a> <a href="https://github.com/sergical"><img src="https://avatars.githubusercontent.com/u/3760543?v=4&s=48" width="48" height="48" alt="sergical" title="sergical"/></a> <a href="https://github.com/shiv19"><img src="https://avatars.githubusercontent.com/u/9407019?v=4&s=48" width="48" height="48" alt="shiv19" title="shiv19"/></a> <a href="https://github.com/shiyuanhai"><img src="https://avatars.githubusercontent.com/u/1187370?v=4&s=48" width="48" height="48" alt="shiyuanhai" title="shiyuanhai"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/testingabc321"><img src="https://avatars.githubusercontent.com/u/8577388?v=4&s=48" width="48" height="48" alt="testingabc321" title="testingabc321"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a>
<a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/search?q=Vultr-Clawd%20Admin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Vultr-Clawd Admin" title="Vultr-Clawd Admin"/></a> <a href="https://github.com/search?q=Wimmie"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Wimmie" title="Wimmie"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/yazinsai"><img src="https://avatars.githubusercontent.com/u/1846034?v=4&s=48" width="48" height="48" alt="yazinsai" title="yazinsai"/></a> <a href="https://github.com/search?q=ymat19"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ymat19" title="ymat19"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/aaronn"><img src="https://avatars.githubusercontent.com/u/1653630?v=4&s=48" width="48" height="48" alt="aaronn" title="aaronn"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a>
<a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/odrobnik"><img src="https://avatars.githubusercontent.com/u/333270?v=4&s=48" width="48" height="48" alt="odrobnik" title="odrobnik"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a>
<a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
<a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/search?q=Vultr-Clawd%20Admin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Vultr-Clawd Admin" title="Vultr-Clawd Admin"/></a> <a href="https://github.com/search?q=Wimmie"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Wimmie" title="Wimmie"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/yazinsai"><img src="https://avatars.githubusercontent.com/u/1846034?v=4&s=48" width="48" height="48" alt="yazinsai" title="yazinsai"/></a> <a href="https://github.com/search?q=ymat19"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ymat19" title="ymat19"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/0xJonHoldsCrypto"><img src="https://avatars.githubusercontent.com/u/81202085?v=4&s=48" width="48" height="48" alt="0xJonHoldsCrypto" title="0xJonHoldsCrypto"/></a> <a href="https://github.com/aaronn"><img src="https://avatars.githubusercontent.com/u/1653630?v=4&s=48" width="48" height="48" alt="aaronn" title="aaronn"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a>
<a href="https://github.com/atalovesyou"><img src="https://avatars.githubusercontent.com/u/3534502?v=4&s=48" width="48" height="48" alt="atalovesyou" title="atalovesyou"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/hougangdev"><img src="https://avatars.githubusercontent.com/u/105773686?v=4&s=48" width="48" height="48" alt="hougangdev" title="hougangdev"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/odrobnik"><img src="https://avatars.githubusercontent.com/u/333270?v=4&s=48" width="48" height="48" alt="odrobnik" title="odrobnik"/></a>
<a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
</p>

View File

@ -788,6 +788,14 @@
{
"source": "/install/railway/",
"destination": "/railway"
},
{
"source": "/gcp",
"destination": "/platforms/gcp"
},
{
"source": "/gcp/",
"destination": "/platforms/gcp"
}
],
"navigation": {
@ -1057,6 +1065,7 @@
"platforms/linux",
"platforms/fly",
"platforms/hetzner",
"platforms/gcp",
"platforms/exe-dev"
]
},

View File

@ -0,0 +1,239 @@
---
summary: "Clawdbot on DigitalOcean (cheapest paid VPS option)"
read_when:
- Setting up Clawdbot on DigitalOcean
- Looking for cheap VPS hosting for Clawdbot
---
# Clawdbot on DigitalOcean
## Goal
Run a persistent Clawdbot Gateway on DigitalOcean for **$6/month** (or $4/mo with reserved pricing).
If you want something even cheaper, see [Oracle Cloud (Free Tier)](#oracle-cloud-free-alternative) at the bottom — it's **actually free forever**.
## Cost Comparison (2026)
| Provider | Plan | Specs | Price/mo | Notes |
|----------|------|-------|----------|-------|
| **Oracle Cloud** | Always Free ARM | 4 OCPU, 24GB RAM | **$0** | Best value, requires ARM-compatible setup |
| **Hetzner** | CX22 | 2 vCPU, 4GB RAM | €3.79 (~$4) | Cheapest paid, EU datacenters |
| **DigitalOcean** | Basic | 1 vCPU, 1GB RAM | $6 | Easy UI, good docs |
| **Vultr** | Cloud Compute | 1 vCPU, 1GB RAM | $6 | Many locations |
| **Linode** | Nanode | 1 vCPU, 1GB RAM | $5 | Now part of Akamai |
**Recommendation:**
- **Free:** Oracle Cloud ARM (if you can handle the signup process)
- **Paid:** Hetzner CX22 (best specs per dollar) — see [Hetzner guide](/platforms/hetzner)
- **Easy:** DigitalOcean (this guide) — beginner-friendly UI
---
## Prerequisites
- DigitalOcean account ([signup with $200 free credit](https://m.do.co/c/signup))
- SSH key pair (or willingness to use password auth)
- ~20 minutes
## 1) Create a Droplet
1. Log into [DigitalOcean](https://cloud.digitalocean.com/)
2. Click **Create → Droplets**
3. Choose:
- **Region:** Closest to you (or your users)
- **Image:** Ubuntu 24.04 LTS
- **Size:** Basic → Regular → **$6/mo** (1 vCPU, 1GB RAM, 25GB SSD)
- **Authentication:** SSH key (recommended) or password
4. Click **Create Droplet**
5. Note the IP address
## 2) Connect via SSH
```bash
ssh root@YOUR_DROPLET_IP
```
## 3) Install Clawdbot
```bash
# Update system
apt update && apt upgrade -y
# Install Node.js 22
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
apt install -y nodejs
# Install Clawdbot
curl -fsSL https://clawd.bot/install.sh | bash
# Verify
clawdbot --version
```
## 4) Run Onboarding
```bash
clawdbot onboard --install-daemon
```
The wizard will walk you through:
- Model auth (API keys or OAuth)
- Channel setup (Telegram, WhatsApp, Discord, etc.)
- Gateway token (auto-generated)
- Daemon installation (systemd)
## 5) Verify the Gateway
```bash
# Check status
clawdbot status
# Check service
systemctl status clawdbot
# View logs
journalctl -u clawdbot -f
```
## 6) Access the Dashboard
The gateway binds to loopback by default. To access the Control UI:
**Option A: SSH Tunnel (recommended)**
```bash
# From your local machine
ssh -L 18789:localhost:18789 root@YOUR_DROPLET_IP
# Then open: http://localhost:18789
```
**Option B: Tailscale (easier long-term)**
```bash
# On the droplet
curl -fsSL https://tailscale.com/install.sh | sh
tailscale up
# Configure gateway to bind to Tailscale
clawdbot config set gateway.bind tailnet
clawdbot gateway restart
```
Then access via your Tailscale IP: `http://100.x.x.x:18789`
## 7) Connect Your Channels
### Telegram
```bash
clawdbot pairing list telegram
clawdbot pairing approve telegram <CODE>
```
### WhatsApp
```bash
clawdbot channels login whatsapp
# Scan QR code
```
See [Channels](/channels) for other providers.
---
## Optimizations for 1GB RAM
The $6 droplet only has 1GB RAM. To keep things running smoothly:
### Add swap (recommended)
```bash
fallocate -l 2G /swapfile
chmod 600 /swapfile
mkswap /swapfile
swapon /swapfile
echo '/swapfile none swap sw 0 0' >> /etc/fstab
```
### Use a lighter model
If you're hitting OOMs, consider:
- Using API-based models (Claude, GPT) instead of local models
- Setting `agents.defaults.model.primary` to a smaller model
### Monitor memory
```bash
free -h
htop
```
---
## Persistence
All state lives in:
- `~/.clawdbot/` — config, credentials, session data
- `~/clawd/` — workspace (SOUL.md, memory, etc.)
These survive reboots. Back them up periodically:
```bash
tar -czvf clawdbot-backup.tar.gz ~/.clawdbot ~/clawd
```
---
## Oracle Cloud Free Alternative
Oracle Cloud offers **Always Free** ARM instances that are significantly more powerful:
| What you get | Specs |
|--------------|-------|
| **4 OCPUs** | ARM Ampere A1 |
| **24GB RAM** | More than enough |
| **200GB storage** | Block volume |
| **Forever free** | No credit card charges |
### Quick setup:
1. Sign up at [oracle.com/cloud/free](https://www.oracle.com/cloud/free/)
2. Create a VM.Standard.A1.Flex instance (ARM)
3. Choose Oracle Linux or Ubuntu
4. Allocate up to 4 OCPU / 24GB RAM within free tier
5. Follow the same Clawdbot install steps above
**Caveats:**
- Signup can be finicky (retry if it fails)
- ARM architecture — most things work, but some binaries need ARM builds
- Oracle may reclaim idle instances (keep them active)
For the full Oracle guide, see the [community docs](https://gist.github.com/rssnyder/51e3cfedd730e7dd5f4a816143b25dbd).
---
## Troubleshooting
### Gateway won't start
```bash
clawdbot gateway status
clawdbot doctor --non-interactive
journalctl -u clawdbot --no-pager -n 50
```
### Port already in use
```bash
lsof -i :18789
kill <PID>
```
### Out of memory
```bash
# Check memory
free -h
# Add more swap
# Or upgrade to $12/mo droplet (2GB RAM)
```
---
## See Also
- [Hetzner guide](/platforms/hetzner) — cheaper, more powerful
- [Docker install](/install/docker) — containerized setup
- [Tailscale](/gateway/tailscale) — secure remote access
- [Configuration](/gateway/configuration) — full config reference

498
docs/platforms/gcp.md Normal file
View File

@ -0,0 +1,498 @@
---
summary: "Run Clawdbot Gateway 24/7 on a GCP Compute Engine VM (Docker) with durable state"
read_when:
- You want Clawdbot running 24/7 on GCP
- You want a production-grade, always-on Gateway on your own VM
- You want full control over persistence, binaries, and restart behavior
---
# Clawdbot on GCP Compute Engine (Docker, Production VPS Guide)
## Goal
Run a persistent Clawdbot Gateway on a GCP Compute Engine VM using Docker, with durable state, baked-in binaries, and safe restart behavior.
If you want "Clawdbot 24/7 for ~$5-12/mo", this is a reliable setup on Google Cloud.
Pricing varies by machine type and region; pick the smallest VM that fits your workload and scale up if you hit OOMs.
## What are we doing (simple terms)?
- Create a GCP project and enable billing
- Create a Compute Engine VM
- Install Docker (isolated app runtime)
- Start the Clawdbot Gateway in Docker
- Persist `~/.clawdbot` + `~/clawd` on the host (survives restarts/rebuilds)
- Access the Control UI from your laptop via an SSH tunnel
The Gateway can be accessed via:
- SSH port forwarding from your laptop
- Direct port exposure if you manage firewalling and tokens yourself
This guide uses Debian on GCP Compute Engine.
Ubuntu also works; map packages accordingly.
For the generic Docker flow, see [Docker](/install/docker).
---
## Quick path (experienced operators)
1) Create GCP project + enable Compute Engine API
2) Create Compute Engine VM (e2-small, Debian 12, 20GB)
3) SSH into the VM
4) Install Docker
5) Clone Clawdbot repository
6) Create persistent host directories
7) Configure `.env` and `docker-compose.yml`
8) Bake required binaries, build, and launch
---
## What you need
- GCP account (free tier eligible for e2-micro)
- gcloud CLI installed (or use Cloud Console)
- SSH access from your laptop
- Basic comfort with SSH + copy/paste
- ~20-30 minutes
- Docker and Docker Compose
- Model auth credentials
- Optional provider credentials
- WhatsApp QR
- Telegram bot token
- Gmail OAuth
---
## 1) Install gcloud CLI (or use Console)
**Option A: gcloud CLI** (recommended for automation)
Install from https://cloud.google.com/sdk/docs/install
Initialize and authenticate:
```bash
gcloud init
gcloud auth login
```
**Option B: Cloud Console**
All steps can be done via the web UI at https://console.cloud.google.com
---
## 2) Create a GCP project
**CLI:**
```bash
gcloud projects create my-clawdbot-project --name="Clawdbot Gateway"
gcloud config set project my-clawdbot-project
```
Enable billing at https://console.cloud.google.com/billing (required for Compute Engine).
Enable the Compute Engine API:
```bash
gcloud services enable compute.googleapis.com
```
**Console:**
1. Go to IAM & Admin > Create Project
2. Name it and create
3. Enable billing for the project
4. Navigate to APIs & Services > Enable APIs > search "Compute Engine API" > Enable
---
## 3) Create the VM
**Machine types:**
| Type | Specs | Cost | Notes |
|------|-------|------|-------|
| e2-small | 2 vCPU, 2GB RAM | ~$12/mo | Recommended |
| e2-micro | 2 vCPU (shared), 1GB RAM | Free tier eligible | May OOM under load |
**CLI:**
```bash
gcloud compute instances create clawdbot-gateway \
--zone=us-central1-a \
--machine-type=e2-small \
--boot-disk-size=20GB \
--image-family=debian-12 \
--image-project=debian-cloud
```
**Console:**
1. Go to Compute Engine > VM instances > Create instance
2. Name: `clawdbot-gateway`
3. Region: `us-central1`, Zone: `us-central1-a`
4. Machine type: `e2-small`
5. Boot disk: Debian 12, 20GB
6. Create
---
## 4) SSH into the VM
**CLI:**
```bash
gcloud compute ssh clawdbot-gateway --zone=us-central1-a
```
**Console:**
Click the "SSH" button next to your VM in the Compute Engine dashboard.
Note: SSH key propagation can take 1-2 minutes after VM creation. If connection is refused, wait and retry.
---
## 5) Install Docker (on the VM)
```bash
sudo apt-get update
sudo apt-get install -y git curl ca-certificates
curl -fsSL https://get.docker.com | sudo sh
sudo usermod -aG docker $USER
```
Log out and back in for the group change to take effect:
```bash
exit
```
Then SSH back in:
```bash
gcloud compute ssh clawdbot-gateway --zone=us-central1-a
```
Verify:
```bash
docker --version
docker compose version
```
---
## 6) Clone the Clawdbot repository
```bash
git clone https://github.com/clawdbot/clawdbot.git
cd clawdbot
```
This guide assumes you will build a custom image to guarantee binary persistence.
---
## 7) Create persistent host directories
Docker containers are ephemeral.
All long-lived state must live on the host.
```bash
mkdir -p ~/.clawdbot
mkdir -p ~/clawd
```
---
## 8) Configure environment variables
Create `.env` in the repository root.
```bash
CLAWDBOT_IMAGE=clawdbot:latest
CLAWDBOT_GATEWAY_TOKEN=change-me-now
CLAWDBOT_GATEWAY_BIND=lan
CLAWDBOT_GATEWAY_PORT=18789
CLAWDBOT_CONFIG_DIR=/home/$USER/.clawdbot
CLAWDBOT_WORKSPACE_DIR=/home/$USER/clawd
GOG_KEYRING_PASSWORD=change-me-now
XDG_CONFIG_HOME=/home/node/.clawdbot
```
Generate strong secrets:
```bash
openssl rand -hex 32
```
**Do not commit this file.**
---
## 9) Docker Compose configuration
Create or update `docker-compose.yml`.
```yaml
services:
clawdbot-gateway:
image: ${CLAWDBOT_IMAGE}
build: .
restart: unless-stopped
env_file:
- .env
environment:
- HOME=/home/node
- NODE_ENV=production
- TERM=xterm-256color
- CLAWDBOT_GATEWAY_BIND=${CLAWDBOT_GATEWAY_BIND}
- CLAWDBOT_GATEWAY_PORT=${CLAWDBOT_GATEWAY_PORT}
- CLAWDBOT_GATEWAY_TOKEN=${CLAWDBOT_GATEWAY_TOKEN}
- GOG_KEYRING_PASSWORD=${GOG_KEYRING_PASSWORD}
- XDG_CONFIG_HOME=${XDG_CONFIG_HOME}
- PATH=/home/linuxbrew/.linuxbrew/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
volumes:
- ${CLAWDBOT_CONFIG_DIR}:/home/node/.clawdbot
- ${CLAWDBOT_WORKSPACE_DIR}:/home/node/clawd
ports:
# Recommended: keep the Gateway loopback-only on the VM; access via SSH tunnel.
# To expose it publicly, remove the `127.0.0.1:` prefix and firewall accordingly.
- "127.0.0.1:${CLAWDBOT_GATEWAY_PORT}:18789"
# Optional: only if you run iOS/Android nodes against this VM and need Canvas host.
# If you expose this publicly, read /gateway/security and firewall accordingly.
# - "18793:18793"
command:
[
"node",
"dist/index.js",
"gateway",
"--bind",
"${CLAWDBOT_GATEWAY_BIND}",
"--port",
"${CLAWDBOT_GATEWAY_PORT}"
]
```
---
## 10) Bake required binaries into the image (critical)
Installing binaries inside a running container is a trap.
Anything installed at runtime will be lost on restart.
All external binaries required by skills must be installed at image build time.
The examples below show three common binaries only:
- `gog` for Gmail access
- `goplaces` for Google Places
- `wacli` for WhatsApp
These are examples, not a complete list.
You may install as many binaries as needed using the same pattern.
If you add new skills later that depend on additional binaries, you must:
1. Update the Dockerfile
2. Rebuild the image
3. Restart the containers
**Example Dockerfile**
```dockerfile
FROM node:22-bookworm
RUN apt-get update && apt-get install -y socat && rm -rf /var/lib/apt/lists/*
# Example binary 1: Gmail CLI
RUN curl -L https://github.com/steipete/gog/releases/latest/download/gog_Linux_x86_64.tar.gz \
| tar -xz -C /usr/local/bin && chmod +x /usr/local/bin/gog
# Example binary 2: Google Places CLI
RUN curl -L https://github.com/steipete/goplaces/releases/latest/download/goplaces_Linux_x86_64.tar.gz \
| tar -xz -C /usr/local/bin && chmod +x /usr/local/bin/goplaces
# Example binary 3: WhatsApp CLI
RUN curl -L https://github.com/steipete/wacli/releases/latest/download/wacli_Linux_x86_64.tar.gz \
| tar -xz -C /usr/local/bin && chmod +x /usr/local/bin/wacli
# Add more binaries below using the same pattern
WORKDIR /app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
COPY ui/package.json ./ui/package.json
COPY scripts ./scripts
RUN corepack enable
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
RUN pnpm ui:install
RUN pnpm ui:build
ENV NODE_ENV=production
CMD ["node","dist/index.js"]
```
---
## 11) Build and launch
```bash
docker compose build
docker compose up -d clawdbot-gateway
```
Verify binaries:
```bash
docker compose exec clawdbot-gateway which gog
docker compose exec clawdbot-gateway which goplaces
docker compose exec clawdbot-gateway which wacli
```
Expected output:
```
/usr/local/bin/gog
/usr/local/bin/goplaces
/usr/local/bin/wacli
```
---
## 12) Verify Gateway
```bash
docker compose logs -f clawdbot-gateway
```
Success:
```
[gateway] listening on ws://0.0.0.0:18789
```
---
## 13) Access from your laptop
Create an SSH tunnel to forward the Gateway port:
```bash
gcloud compute ssh clawdbot-gateway --zone=us-central1-a -- -L 18789:127.0.0.1:18789
```
Open in your browser:
`http://127.0.0.1:18789/`
Paste your gateway token.
---
## What persists where (source of truth)
Clawdbot runs in Docker, but Docker is not the source of truth.
All long-lived state must survive restarts, rebuilds, and reboots.
| Component | Location | Persistence mechanism | Notes |
|---|---|---|---|
| Gateway config | `/home/node/.clawdbot/` | Host volume mount | Includes `clawdbot.json`, tokens |
| Model auth profiles | `/home/node/.clawdbot/` | Host volume mount | OAuth tokens, API keys |
| Skill configs | `/home/node/.clawdbot/skills/` | Host volume mount | Skill-level state |
| Agent workspace | `/home/node/clawd/` | Host volume mount | Code and agent artifacts |
| WhatsApp session | `/home/node/.clawdbot/` | Host volume mount | Preserves QR login |
| Gmail keyring | `/home/node/.clawdbot/` | Host volume + password | Requires `GOG_KEYRING_PASSWORD` |
| External binaries | `/usr/local/bin/` | Docker image | Must be baked at build time |
| Node runtime | Container filesystem | Docker image | Rebuilt every image build |
| OS packages | Container filesystem | Docker image | Do not install at runtime |
| Docker container | Ephemeral | Restartable | Safe to destroy |
---
## Updates
To update Clawdbot on the VM:
```bash
cd ~/clawdbot
git pull
docker compose build
docker compose up -d
```
---
## Troubleshooting
**SSH connection refused**
SSH key propagation can take 1-2 minutes after VM creation. Wait and retry.
**OS Login issues**
Check your OS Login profile:
```bash
gcloud compute os-login describe-profile
```
Ensure your account has the required IAM permissions (Compute OS Login or Compute OS Admin Login).
**Out of memory (OOM)**
If using e2-micro and hitting OOM, upgrade to e2-small or e2-medium:
```bash
# Stop the VM first
gcloud compute instances stop clawdbot-gateway --zone=us-central1-a
# Change machine type
gcloud compute instances set-machine-type clawdbot-gateway \
--zone=us-central1-a \
--machine-type=e2-small
# Start the VM
gcloud compute instances start clawdbot-gateway --zone=us-central1-a
```
---
## Service accounts (security best practice)
For personal use, your default user account works fine.
For automation or CI/CD pipelines, create a dedicated service account with minimal permissions:
1. Create a service account:
```bash
gcloud iam service-accounts create clawdbot-deploy \
--display-name="Clawdbot Deployment"
```
2. Grant Compute Instance Admin role (or narrower custom role):
```bash
gcloud projects add-iam-policy-binding my-clawdbot-project \
--member="serviceAccount:clawdbot-deploy@my-clawdbot-project.iam.gserviceaccount.com" \
--role="roles/compute.instanceAdmin.v1"
```
Avoid using the Owner role for automation. Use the principle of least privilege.
See https://cloud.google.com/iam/docs/understanding-roles for IAM role details.
---
## Next steps
- Set up messaging channels: [Channels](/channels)
- Pair local devices as nodes: [Nodes](/nodes)
- Configure the Gateway: [Gateway configuration](/gateway/configuration)

View File

@ -27,6 +27,7 @@ Native companion apps for Windows are also planned; the Gateway is recommended v
- Railway (one-click): [Railway](/railway)
- Fly.io: [Fly.io](/platforms/fly)
- Hetzner (Docker): [Hetzner](/platforms/hetzner)
- GCP (Compute Engine): [GCP](/platforms/gcp)
- exe.dev (VM + HTTPS proxy): [exe.dev](/platforms/exe-dev)
## Common links

View File

@ -0,0 +1,354 @@
---
summary: "Clawdbot on Raspberry Pi (budget self-hosted setup)"
read_when:
- Setting up Clawdbot on a Raspberry Pi
- Running Clawdbot on ARM devices
- Building a cheap always-on personal AI
---
# Clawdbot on Raspberry Pi
## Goal
Run a persistent, always-on Clawdbot Gateway on a Raspberry Pi for **~$35-80** one-time cost (no monthly fees).
Perfect for:
- 24/7 personal AI assistant
- Home automation hub
- Low-power, always-available Telegram/WhatsApp bot
## Hardware Requirements
| Pi Model | RAM | Works? | Notes |
|----------|-----|--------|-------|
| **Pi 5** | 4GB/8GB | ✅ Best | Fastest, recommended |
| **Pi 4** | 4GB | ✅ Good | Sweet spot for most users |
| **Pi 4** | 2GB | ✅ OK | Works, add swap |
| **Pi 4** | 1GB | ⚠️ Tight | Possible with swap, minimal config |
| **Pi 3B+** | 1GB | ⚠️ Slow | Works but sluggish |
| **Pi Zero 2 W** | 512MB | ❌ | Not recommended |
**Minimum specs:** 1GB RAM, 1 core, 500MB disk
**Recommended:** 2GB+ RAM, 64-bit OS, 16GB+ SD card (or USB SSD)
## What You'll Need
- Raspberry Pi 4 or 5 (2GB+ recommended)
- MicroSD card (16GB+) or USB SSD (better performance)
- Power supply (official Pi PSU recommended)
- Network connection (Ethernet or WiFi)
- ~30 minutes
## 1) Flash the OS
Use **Raspberry Pi OS Lite (64-bit)** — no desktop needed for a headless server.
1. Download [Raspberry Pi Imager](https://www.raspberrypi.com/software/)
2. Choose OS: **Raspberry Pi OS Lite (64-bit)**
3. Click the gear icon (⚙️) to pre-configure:
- Set hostname: `gateway-host`
- Enable SSH
- Set username/password
- Configure WiFi (if not using Ethernet)
4. Flash to your SD card / USB drive
5. Insert and boot the Pi
## 2) Connect via SSH
```bash
ssh user@gateway-host
# or use the IP address
ssh user@192.168.x.x
```
## 3) System Setup
```bash
# Update system
sudo apt update && sudo apt upgrade -y
# Install essential packages
sudo apt install -y git curl build-essential
# Set timezone (important for cron/reminders)
sudo timedatectl set-timezone America/Chicago # Change to your timezone
```
## 4) Install Node.js 22 (ARM64)
```bash
# Install Node.js via NodeSource
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt install -y nodejs
# Verify
node --version # Should show v22.x.x
npm --version
```
## 5) Add Swap (Important for 2GB or less)
Swap prevents out-of-memory crashes:
```bash
# Create 2GB swap file
sudo fallocate -l 2G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
# Make permanent
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
# Optimize for low RAM (reduce swappiness)
echo 'vm.swappiness=10' | sudo tee -a /etc/sysctl.conf
sudo sysctl -p
```
## 6) Install Clawdbot
### Option A: Standard Install (Recommended)
```bash
curl -fsSL https://clawd.bot/install.sh | bash
```
### Option B: Hackable Install (For tinkering)
```bash
git clone https://github.com/clawdbot/clawdbot.git
cd clawdbot
npm install
npm run build
npm link
```
The hackable install gives you direct access to logs and code — useful for debugging ARM-specific issues.
## 7) Run Onboarding
```bash
clawdbot onboard --install-daemon
```
Follow the wizard:
1. **Gateway mode:** Local
2. **Auth:** API keys recommended (OAuth can be finicky on headless Pi)
3. **Channels:** Telegram is easiest to start with
4. **Daemon:** Yes (systemd)
## 8) Verify Installation
```bash
# Check status
clawdbot status
# Check service
sudo systemctl status clawdbot
# View logs
journalctl -u clawdbot -f
```
## 9) Access the Dashboard
Since the Pi is headless, use an SSH tunnel:
```bash
# From your laptop/desktop
ssh -L 18789:localhost:18789 user@gateway-host
# Then open in browser
open http://localhost:18789
```
Or use Tailscale for always-on access:
```bash
# On the Pi
curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up
# Update config
clawdbot config set gateway.bind tailnet
sudo systemctl restart clawdbot
```
---
## Performance Optimizations
### Use a USB SSD (Huge Improvement)
SD cards are slow and wear out. A USB SSD dramatically improves performance:
```bash
# Check if booting from USB
lsblk
```
See [Pi USB boot guide](https://www.raspberrypi.com/documentation/computers/raspberry-pi.html#usb-mass-storage-boot) for setup.
### Reduce Memory Usage
```bash
# Disable GPU memory allocation (headless)
echo 'gpu_mem=16' | sudo tee -a /boot/config.txt
# Disable Bluetooth if not needed
sudo systemctl disable bluetooth
```
### Monitor Resources
```bash
# Check memory
free -h
# Check CPU temperature
vcgencmd measure_temp
# Live monitoring
htop
```
---
## ARM-Specific Notes
### Binary Compatibility
Most Clawdbot features work on ARM64, but some external binaries may need ARM builds:
| Tool | ARM64 Status | Notes |
|------|--------------|-------|
| Node.js | ✅ | Works great |
| WhatsApp (Baileys) | ✅ | Pure JS, no issues |
| Telegram | ✅ | Pure JS, no issues |
| gog (Gmail CLI) | ⚠️ | Check for ARM release |
| Chromium (browser) | ✅ | `sudo apt install chromium-browser` |
If a skill fails, check if its binary has an ARM build. Many Go/Rust tools do; some don't.
### 32-bit vs 64-bit
**Always use 64-bit OS.** Node.js and many modern tools require it. Check with:
```bash
uname -m
# Should show: aarch64 (64-bit) not armv7l (32-bit)
```
---
## Recommended Model Setup
Since the Pi is just the Gateway (models run in the cloud), use API-based models:
```json
{
"agents": {
"defaults": {
"model": {
"primary": "anthropic/claude-sonnet-4-20250514",
"fallbacks": ["openai/gpt-4o-mini"]
}
}
}
}
```
**Don't try to run local LLMs on a Pi** — even small models are too slow. Let Claude/GPT do the heavy lifting.
---
## Auto-Start on Boot
The onboarding wizard sets this up, but to verify:
```bash
# Check service is enabled
sudo systemctl is-enabled clawdbot
# Enable if not
sudo systemctl enable clawdbot
# Start on boot
sudo systemctl start clawdbot
```
---
## Troubleshooting
### Out of Memory (OOM)
```bash
# Check memory
free -h
# Add more swap (see Step 5)
# Or reduce services running on the Pi
```
### Slow Performance
- Use USB SSD instead of SD card
- Disable unused services: `sudo systemctl disable cups bluetooth avahi-daemon`
- Check CPU throttling: `vcgencmd get_throttled` (should return `0x0`)
### Service Won't Start
```bash
# Check logs
journalctl -u clawdbot --no-pager -n 100
# Common fix: rebuild
cd ~/clawdbot # if using hackable install
npm run build
sudo systemctl restart clawdbot
```
### ARM Binary Issues
If a skill fails with "exec format error":
1. Check if the binary has an ARM64 build
2. Try building from source
3. Or use a Docker container with ARM support
### WiFi Drops
For headless Pis on WiFi:
```bash
# Disable WiFi power management
sudo iwconfig wlan0 power off
# Make permanent
echo 'wireless-power off' | sudo tee -a /etc/network/interfaces
```
---
## Cost Comparison
| Setup | One-Time Cost | Monthly Cost | Notes |
|-------|---------------|--------------|-------|
| **Pi 4 (2GB)** | ~$45 | $0 | + power (~$5/yr) |
| **Pi 4 (4GB)** | ~$55 | $0 | Recommended |
| **Pi 5 (4GB)** | ~$60 | $0 | Best performance |
| **Pi 5 (8GB)** | ~$80 | $0 | Overkill but future-proof |
| DigitalOcean | $0 | $6/mo | $72/year |
| Hetzner | $0 | €3.79/mo | ~$50/year |
**Break-even:** A Pi pays for itself in ~6-12 months vs cloud VPS.
---
## See Also
- [Linux guide](/platforms/linux) — general Linux setup
- [DigitalOcean guide](/platforms/digitalocean) — cloud alternative
- [Hetzner guide](/platforms/hetzner) — Docker setup
- [Tailscale](/gateway/tailscale) — remote access
- [Nodes](/nodes) — pair your laptop/phone with the Pi gateway

View File

@ -0,0 +1,145 @@
---
summary: "Use Claude Max/Pro subscription as an OpenAI-compatible API endpoint"
read_when:
- You want to use Claude Max subscription with OpenAI-compatible tools
- You want a local API server that wraps Claude Code CLI
- You want to save money by using subscription instead of API keys
---
# Claude Max API Proxy
**claude-max-api-proxy** is a community tool that exposes your Claude Max/Pro subscription as an OpenAI-compatible API endpoint. This allows you to use your subscription with any tool that supports the OpenAI API format.
## Why Use This?
| Approach | Cost | Best For |
|----------|------|----------|
| Anthropic API | Pay per token (~$15/M input, $75/M output for Opus) | Production apps, high volume |
| Claude Max subscription | $200/month flat | Personal use, development, unlimited usage |
If you have a Claude Max subscription and want to use it with OpenAI-compatible tools, this proxy can save you significant money.
## How It Works
```
Your App → claude-max-api-proxy → Claude Code CLI → Anthropic (via subscription)
(OpenAI format) (converts format) (uses your login)
```
The proxy:
1. Accepts OpenAI-format requests at `http://localhost:3456/v1/chat/completions`
2. Converts them to Claude Code CLI commands
3. Returns responses in OpenAI format (streaming supported)
## Installation
```bash
# Requires Node.js 20+ and Claude Code CLI
npm install -g claude-max-api-proxy
# Verify Claude CLI is authenticated
claude --version
```
## Usage
### Start the server
```bash
claude-max-api
# Server runs at http://localhost:3456
```
### Test it
```bash
# Health check
curl http://localhost:3456/health
# List models
curl http://localhost:3456/v1/models
# Chat completion
curl http://localhost:3456/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "claude-opus-4",
"messages": [{"role": "user", "content": "Hello!"}]
}'
```
### With Clawdbot
You can point Clawdbot at the proxy as a custom OpenAI-compatible endpoint:
```json5
{
env: {
OPENAI_API_KEY: "not-needed",
OPENAI_BASE_URL: "http://localhost:3456/v1"
},
agents: {
defaults: {
model: { primary: "openai/claude-opus-4" }
}
}
}
```
## Available Models
| Model ID | Maps To |
|----------|---------|
| `claude-opus-4` | Claude Opus 4 |
| `claude-sonnet-4` | Claude Sonnet 4 |
| `claude-haiku-4` | Claude Haiku 4 |
## Auto-Start on macOS
Create a LaunchAgent to run the proxy automatically:
```bash
cat > ~/Library/LaunchAgents/com.claude-max-api.plist << 'EOF'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.claude-max-api</string>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>ProgramArguments</key>
<array>
<string>/usr/local/bin/node</string>
<string>/usr/local/lib/node_modules/claude-max-api-proxy/dist/server/standalone.js</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/usr/local/bin:/opt/homebrew/bin:~/.local/bin:/usr/bin:/bin</string>
</dict>
</dict>
</plist>
EOF
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.claude-max-api.plist
```
## Links
- **npm:** https://www.npmjs.com/package/claude-max-api-proxy
- **GitHub:** https://github.com/atalovesyou/claude-max-api-proxy
- **Issues:** https://github.com/atalovesyou/claude-max-api-proxy/issues
## Notes
- This is a **community tool**, not officially supported by Anthropic or Clawdbot
- Requires an active Claude Max/Pro subscription with Claude Code CLI authenticated
- The proxy runs locally and does not send data to any third-party servers
- Streaming responses are fully supported
## See Also
- [Anthropic provider](/providers/anthropic) - Native Clawdbot integration with Claude Code CLI OAuth
- [OpenAI provider](/providers/openai) - For OpenAI/Codex subscriptions

View File

@ -51,5 +51,9 @@ See [Venice AI](/providers/venice).
- [Deepgram (audio transcription)](/providers/deepgram)
## Community tools
- [Claude Max API Proxy](/providers/claude-max-api-proxy) - Use Claude Max/Pro subscription as an OpenAI-compatible API endpoint
For the full provider catalog (xAI, Groq, Mistral, etc.) and advanced configuration,
see [Model providers](/concepts/model-providers).

View File

@ -14,6 +14,7 @@ deployments work at a high level.
- **Railway** (oneclick + browser setup): [Railway](/railway)
- **Fly.io**: [Fly.io](/platforms/fly)
- **Hetzner (Docker)**: [Hetzner](/platforms/hetzner)
- **GCP (Compute Engine)**: [GCP](/platforms/gcp)
- **exe.dev** (VM + HTTPS proxy): [exe.dev](/platforms/exe-dev)
- **AWS (EC2/Lightsail/free tier)**: works well too. Video guide:
https://x.com/techfrenAJ/status/2014934471095812547

View File

@ -63,16 +63,28 @@ export async function sendGroupMessage({
const story = [{ inline: [text] }];
const sentAt = Date.now();
// Format reply ID as @ud (with dots) - required for Tlon to recognize thread replies
let formattedReplyId = replyToId;
if (replyToId && /^\d+$/.test(replyToId)) {
try {
formattedReplyId = formatUd(BigInt(replyToId));
} catch {
// Fall back to raw ID if formatting fails
}
}
const action = {
channel: {
nest: `chat/${hostShip}/${channelName}`,
action: replyToId
action: formattedReplyId
? {
reply: {
id: replyToId,
delta: {
add: {
memo: {
// Thread reply - needs post wrapper around reply action
// ReplyActionAdd takes Memo: {content, author, sent} - no kind/blob/meta
post: {
reply: {
id: formattedReplyId,
action: {
add: {
content: story,
author: fromShip,
sent: sentAt,
@ -82,6 +94,7 @@ export async function sendGroupMessage({
},
}
: {
// Regular post
post: {
add: {
content: story,

View File

@ -12,7 +12,10 @@
"manmal",
"thesash",
"rhjoh",
"ysqander"
"ysqander",
"atalovesyou",
"0xJonHoldsCrypto",
"hougangdev"
],
"seedCommit": "d6863f87",
"placeholderAvatar": "assets/avatar-placeholder.svg",

View File

@ -52,7 +52,7 @@ export function registerOnboardCommand(program: Command) {
.option("--mode <mode>", "Wizard mode: local|remote")
.option(
"--auth-choice <choice>",
"Auth: setup-token|claude-cli|token|chutes|openai-codex|openai-api-key|openrouter-api-key|ai-gateway-api-key|moonshot-api-key|kimi-code-api-key|synthetic-api-key|codex-cli|gemini-api-key|zai-api-key|apiKey|minimax-api|minimax-api-lightning|opencode-zen|skip",
"Auth: setup-token|claude-cli|token|chutes|openai-codex|openai-api-key|openrouter-api-key|ai-gateway-api-key|moonshot-api-key|kimi-code-api-key|synthetic-api-key|venice-api-key|codex-cli|gemini-api-key|zai-api-key|apiKey|minimax-api|minimax-api-lightning|opencode-zen|skip",
)
.option(
"--token-provider <id>",
@ -74,6 +74,7 @@ export function registerOnboardCommand(program: Command) {
.option("--zai-api-key <key>", "Z.AI API key")
.option("--minimax-api-key <key>", "MiniMax API key")
.option("--synthetic-api-key <key>", "Synthetic API key")
.option("--venice-api-key <key>", "Venice API key")
.option("--opencode-zen-api-key <key>", "OpenCode Zen API key")
.option("--gateway-port <port>", "Gateway port")
.option("--gateway-bind <mode>", "Gateway bind: loopback|tailnet|lan|auto|custom")
@ -123,6 +124,7 @@ export function registerOnboardCommand(program: Command) {
zaiApiKey: opts.zaiApiKey as string | undefined,
minimaxApiKey: opts.minimaxApiKey as string | undefined,
syntheticApiKey: opts.syntheticApiKey as string | undefined,
veniceApiKey: opts.veniceApiKey as string | undefined,
opencodeZenApiKey: opts.opencodeZenApiKey as string | undefined,
gatewayPort:
typeof gatewayPort === "number" && Number.isFinite(gatewayPort)

View File

@ -20,6 +20,7 @@ import {
applyOpencodeZenConfig,
applyOpenrouterConfig,
applySyntheticConfig,
applyVeniceConfig,
applyVercelAiGatewayConfig,
applyZaiConfig,
setAnthropicApiKey,
@ -30,6 +31,7 @@ import {
setOpencodeZenApiKey,
setOpenrouterApiKey,
setSyntheticApiKey,
setVeniceApiKey,
setVercelAiGatewayApiKey,
setZaiApiKey,
} from "../../onboard-auth.js";
@ -272,6 +274,25 @@ export async function applyNonInteractiveAuthChoice(params: {
return applySyntheticConfig(nextConfig);
}
if (authChoice === "venice-api-key") {
const resolved = await resolveNonInteractiveApiKey({
provider: "venice",
cfg: baseConfig,
flagValue: opts.veniceApiKey,
flagName: "--venice-api-key",
envVar: "VENICE_API_KEY",
runtime,
});
if (!resolved) return null;
if (resolved.source !== "profile") await setVeniceApiKey(resolved.key);
nextConfig = applyAuthProfileConfig(nextConfig, {
profileId: "venice:default",
provider: "venice",
mode: "api_key",
});
return applyVeniceConfig(nextConfig);
}
if (
authChoice === "minimax-cloud" ||
authChoice === "minimax-api" ||

View File

@ -35,7 +35,7 @@ export const ChatHistoryParamsSchema = Type.Object(
export const ChatSendParamsSchema = Type.Object(
{
sessionKey: NonEmptyString,
message: NonEmptyString,
message: Type.String(),
thinking: Type.Optional(Type.String()),
deliver: Type.Optional(Type.Boolean()),
attachments: Type.Optional(Type.Array(Type.Unknown())),

View File

@ -338,6 +338,15 @@ export const chatHandlers: GatewayRequestHandlers = {
: undefined,
}))
.filter((a) => a.content) ?? [];
const rawMessage = p.message.trim();
if (!rawMessage && normalizedAttachments.length === 0) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "message or attachment required"),
);
return;
}
let parsedMessage = p.message;
let parsedImages: ChatImageContent[] = [];
if (normalizedAttachments.length > 0) {

View File

@ -208,6 +208,39 @@ describe("gateway server chat", () => {
| undefined;
expect(imgOpts?.images).toEqual([{ type: "image", data: pngB64, mimeType: "image/png" }]);
const callsBeforeImageOnly = spy.mock.calls.length;
const reqIdOnly = "chat-img-only";
ws.send(
JSON.stringify({
type: "req",
id: reqIdOnly,
method: "chat.send",
params: {
sessionKey: "main",
message: "",
idempotencyKey: "idem-img-only",
attachments: [
{
type: "image",
mimeType: "image/png",
fileName: "dot.png",
content: `data:image/png;base64,${pngB64}`,
},
],
},
}),
);
const imgOnlyRes = await onceMessage(ws, (o) => o.type === "res" && o.id === reqIdOnly, 8000);
expect(imgOnlyRes.ok).toBe(true);
expect(imgOnlyRes.payload?.runId).toBeDefined();
await waitFor(() => spy.mock.calls.length > callsBeforeImageOnly, 8000);
const imgOnlyOpts = spy.mock.calls.at(-1)?.[1] as
| { images?: Array<{ type: string; data: string; mimeType: string }> }
| undefined;
expect(imgOnlyOpts?.images).toEqual([{ type: "image", data: pngB64, mimeType: "image/png" }]);
const historyDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
tempDirs.push(historyDir);
testState.sessionStorePath = path.join(historyDir, "sessions.json");

View File

@ -381,6 +381,31 @@ export function resolveGatewaySessionStoreTarget(params: { cfg: ClawdbotConfig;
};
}
// Merge with existing entry based on latest timestamp to ensure data consistency and avoid overwriting with less complete data.
function mergeSessionEntryIntoCombined(params: {
combined: Record<string, SessionEntry>;
entry: SessionEntry;
agentId: string;
canonicalKey: string;
}) {
const { combined, entry, agentId, canonicalKey } = params;
const existing = combined[canonicalKey];
if (existing && (existing.updatedAt ?? 0) > (entry.updatedAt ?? 0)) {
combined[canonicalKey] = {
...entry,
...existing,
spawnedBy: canonicalizeSpawnedByForAgent(agentId, existing.spawnedBy ?? entry.spawnedBy),
};
} else {
combined[canonicalKey] = {
...existing,
...entry,
spawnedBy: canonicalizeSpawnedByForAgent(agentId, entry.spawnedBy ?? existing?.spawnedBy),
};
}
}
export function loadCombinedSessionStoreForGateway(cfg: ClawdbotConfig): {
storePath: string;
store: Record<string, SessionEntry>;
@ -393,10 +418,12 @@ export function loadCombinedSessionStoreForGateway(cfg: ClawdbotConfig): {
const combined: Record<string, SessionEntry> = {};
for (const [key, entry] of Object.entries(store)) {
const canonicalKey = canonicalizeSessionKeyForAgent(defaultAgentId, key);
combined[canonicalKey] = {
...entry,
spawnedBy: canonicalizeSpawnedByForAgent(defaultAgentId, entry.spawnedBy),
};
mergeSessionEntryIntoCombined({
combined,
entry,
agentId: defaultAgentId,
canonicalKey,
});
}
return { storePath, store: combined };
}
@ -408,13 +435,12 @@ export function loadCombinedSessionStoreForGateway(cfg: ClawdbotConfig): {
const store = loadSessionStore(storePath);
for (const [key, entry] of Object.entries(store)) {
const canonicalKey = canonicalizeSessionKeyForAgent(agentId, key);
// Merge with existing entry if present (avoid overwriting with less complete data)
const existing = combined[canonicalKey];
combined[canonicalKey] = {
...existing,
...entry,
spawnedBy: canonicalizeSpawnedByForAgent(agentId, entry.spawnedBy ?? existing?.spawnedBy),
};
mergeSessionEntryIntoCombined({
combined,
entry,
agentId,
canonicalKey,
});
}
}

View File

@ -103,7 +103,7 @@
bottom: 0;
flex-shrink: 0;
display: flex;
align-items: stretch;
flex-direction: column;
gap: 12px;
margin-top: auto; /* Push to bottom of flex container */
padding: 12px 4px 4px;
@ -111,6 +111,121 @@
z-index: 10;
}
/* Image attachments preview */
.chat-attachments {
display: inline-flex;
flex-wrap: wrap;
gap: 8px;
padding: 8px;
background: var(--panel);
border-radius: 8px;
border: 1px solid var(--border);
width: fit-content;
max-width: 100%;
align-self: flex-start; /* Don't stretch in flex column parent */
}
.chat-attachment {
position: relative;
width: 80px;
height: 80px;
border-radius: 6px;
overflow: hidden;
border: 1px solid var(--border);
background: var(--bg);
}
.chat-attachment__img {
width: 100%;
height: 100%;
object-fit: contain;
}
.chat-attachment__remove {
position: absolute;
top: 4px;
right: 4px;
width: 20px;
height: 20px;
border-radius: 50%;
border: none;
background: rgba(0, 0, 0, 0.7);
color: #fff;
font-size: 12px;
line-height: 1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 150ms ease-out;
}
.chat-attachment:hover .chat-attachment__remove {
opacity: 1;
}
.chat-attachment__remove:hover {
background: rgba(220, 38, 38, 0.9);
}
.chat-attachment__remove svg {
width: 12px;
height: 12px;
stroke: currentColor;
fill: none;
stroke-width: 2px;
}
/* Light theme attachment overrides */
:root[data-theme="light"] .chat-attachments {
background: #f8fafc;
border-color: rgba(16, 24, 40, 0.1);
}
:root[data-theme="light"] .chat-attachment {
border-color: rgba(16, 24, 40, 0.15);
background: #fff;
}
:root[data-theme="light"] .chat-attachment__remove {
background: rgba(0, 0, 0, 0.6);
}
/* Message images (sent images displayed in chat) */
.chat-message-images {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 8px;
}
.chat-message-image {
max-width: 300px;
max-height: 200px;
border-radius: 8px;
object-fit: contain;
cursor: pointer;
transition: transform 150ms ease-out;
}
.chat-message-image:hover {
transform: scale(1.02);
}
/* User message images align right */
.chat-group.user .chat-message-images {
justify-content: flex-end;
}
/* Compose input row - horizontal layout */
.chat-compose__row {
display: flex;
align-items: stretch;
gap: 12px;
flex: 1;
}
:root[data-theme="light"] .chat-compose {
background: linear-gradient(to bottom, transparent, var(--bg-content) 20%);
}

View File

@ -1303,9 +1303,8 @@
/* Chat compose */
.chat-compose {
margin-top: 12px;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: end;
display: flex;
flex-direction: column;
gap: 10px;
}

View File

@ -8,11 +8,13 @@ import { normalizeBasePath } from "./navigation";
import type { GatewayHelloOk } from "./gateway";
import { parseAgentSessionKey } from "../../../src/sessions/session-key-utils.js";
import type { ClawdbotApp } from "./app";
import type { ChatAttachment, ChatQueueItem } from "./ui-types";
type ChatHost = {
connected: boolean;
chatMessage: string;
chatQueue: Array<{ id: string; text: string; createdAt: number }>;
chatAttachments: ChatAttachment[];
chatQueue: ChatQueueItem[];
chatRunId: string | null;
chatSending: boolean;
sessionKey: string;
@ -45,15 +47,17 @@ export async function handleAbortChat(host: ChatHost) {
await abortChatRun(host as unknown as ClawdbotApp);
}
function enqueueChatMessage(host: ChatHost, text: string) {
function enqueueChatMessage(host: ChatHost, text: string, attachments?: ChatAttachment[]) {
const trimmed = text.trim();
if (!trimmed) return;
const hasAttachments = Boolean(attachments && attachments.length > 0);
if (!trimmed && !hasAttachments) return;
host.chatQueue = [
...host.chatQueue,
{
id: generateUUID(),
text: trimmed,
createdAt: Date.now(),
attachments: hasAttachments ? attachments?.map((att) => ({ ...att })) : undefined,
},
];
}
@ -61,19 +65,31 @@ function enqueueChatMessage(host: ChatHost, text: string) {
async function sendChatMessageNow(
host: ChatHost,
message: string,
opts?: { previousDraft?: string; restoreDraft?: boolean },
opts?: {
previousDraft?: string;
restoreDraft?: boolean;
attachments?: ChatAttachment[];
previousAttachments?: ChatAttachment[];
restoreAttachments?: boolean;
},
) {
resetToolStream(host as unknown as Parameters<typeof resetToolStream>[0]);
const ok = await sendChatMessage(host as unknown as ClawdbotApp, message);
const ok = await sendChatMessage(host as unknown as ClawdbotApp, message, opts?.attachments);
if (!ok && opts?.previousDraft != null) {
host.chatMessage = opts.previousDraft;
}
if (!ok && opts?.previousAttachments) {
host.chatAttachments = opts.previousAttachments;
}
if (ok) {
setLastActiveSessionKey(host as unknown as Parameters<typeof setLastActiveSessionKey>[0], host.sessionKey);
}
if (ok && opts?.restoreDraft && opts.previousDraft?.trim()) {
host.chatMessage = opts.previousDraft;
}
if (ok && opts?.restoreAttachments && opts.previousAttachments?.length) {
host.chatAttachments = opts.previousAttachments;
}
scheduleChatScroll(host as unknown as Parameters<typeof scheduleChatScroll>[0]);
if (ok && !host.chatRunId) {
void flushChatQueue(host);
@ -86,7 +102,7 @@ async function flushChatQueue(host: ChatHost) {
const [next, ...rest] = host.chatQueue;
if (!next) return;
host.chatQueue = rest;
const ok = await sendChatMessageNow(host, next.text);
const ok = await sendChatMessageNow(host, next.text, { attachments: next.attachments });
if (!ok) {
host.chatQueue = [next, ...host.chatQueue];
}
@ -104,7 +120,12 @@ export async function handleSendChat(
if (!host.connected) return;
const previousDraft = host.chatMessage;
const message = (messageOverride ?? host.chatMessage).trim();
if (!message) return;
const attachments = host.chatAttachments ?? [];
const attachmentsToSend = messageOverride == null ? attachments : [];
const hasAttachments = attachmentsToSend.length > 0;
// Allow sending with just attachments (no message text required)
if (!message && !hasAttachments) return;
if (isChatStopCommand(message)) {
await handleAbortChat(host);
@ -113,16 +134,21 @@ export async function handleSendChat(
if (messageOverride == null) {
host.chatMessage = "";
// Clear attachments when sending
host.chatAttachments = [];
}
if (isChatBusy(host)) {
enqueueChatMessage(host, message);
enqueueChatMessage(host, message, attachmentsToSend);
return;
}
await sendChatMessageNow(host, message, {
previousDraft: messageOverride == null ? previousDraft : undefined,
restoreDraft: Boolean(messageOverride && opts?.restoreDraft),
attachments: hasAttachments ? attachmentsToSend : undefined,
previousAttachments: messageOverride == null ? attachments : undefined,
restoreAttachments: Boolean(messageOverride && opts?.restoreDraft),
});
}

View File

@ -431,6 +431,7 @@ export function renderApp(state: AppViewState) {
onSessionKeyChange: (next) => {
state.sessionKey = next;
state.chatMessage = "";
state.chatAttachments = [];
state.chatStream = null;
state.chatStreamStartedAt = null;
state.chatRunId = null;
@ -477,6 +478,8 @@ export function renderApp(state: AppViewState) {
},
onChatScroll: (event) => state.handleChatScroll(event),
onDraftChange: (next) => (state.chatMessage = next),
attachments: state.chatAttachments,
onAttachmentsChange: (next) => (state.chatAttachments = next),
onSend: () => state.handleSendChat(),
canAbort: Boolean(state.chatRunId),
onAbort: () => void state.handleAbortChat(),

View File

@ -19,7 +19,7 @@ import type {
SkillStatusReport,
StatusSummary,
} from "./types";
import type { ChatQueueItem, CronFormState } from "./ui-types";
import type { ChatAttachment, ChatQueueItem, CronFormState } from "./ui-types";
import type { EventLogEntry } from "./app-events";
import type { SkillMessage } from "./controllers/skills";
import type {
@ -49,6 +49,7 @@ export type AppViewState = {
chatLoading: boolean;
chatSending: boolean;
chatMessage: string;
chatAttachments: ChatAttachment[];
chatMessages: unknown[];
chatToolMessages: unknown[];
chatStream: string | null;

View File

@ -24,7 +24,7 @@ import type {
StatusSummary,
NostrProfile,
} from "./types";
import { type ChatQueueItem, type CronFormState } from "./ui-types";
import { type ChatAttachment, type ChatQueueItem, type CronFormState } from "./ui-types";
import type { EventLogEntry } from "./app-events";
import { DEFAULT_CRON_FORM, DEFAULT_LOG_LEVEL_FILTERS } from "./app-defaults";
import type {
@ -129,6 +129,7 @@ export class ClawdbotApp extends LitElement {
@state() chatAvatarUrl: string | null = null;
@state() chatThinkingLevel: string | null = null;
@state() chatQueue: ChatQueueItem[] = [];
@state() chatAttachments: ChatAttachment[] = [];
// Sidebar state for tool output viewing
@state() sidebarOpen = false;
@state() sidebarContent: string | null = null;

View File

@ -13,6 +13,48 @@ import {
} from "./message-extract";
import { extractToolCards, renderToolCardSidebar } from "./tool-cards";
type ImageBlock = {
url: string;
alt?: string;
};
function extractImages(message: unknown): ImageBlock[] {
const m = message as Record<string, unknown>;
const content = m.content;
const images: ImageBlock[] = [];
if (Array.isArray(content)) {
for (const block of content) {
if (typeof block !== "object" || block === null) continue;
const b = block as Record<string, unknown>;
if (b.type === "image") {
// Handle source object format (from sendChatMessage)
const source = b.source as Record<string, unknown> | undefined;
if (source?.type === "base64" && typeof source.data === "string") {
const data = source.data as string;
const mediaType = (source.media_type as string) || "image/png";
// If data is already a data URL, use it directly
const url = data.startsWith("data:")
? data
: `data:${mediaType};base64,${data}`;
images.push({ url });
} else if (typeof b.url === "string") {
images.push({ url: b.url });
}
} else if (b.type === "image_url") {
// OpenAI format
const imageUrl = b.image_url as Record<string, unknown> | undefined;
if (typeof imageUrl?.url === "string") {
images.push({ url: imageUrl.url });
}
}
}
}
return images;
}
export function renderReadingIndicatorGroup(assistant?: AssistantIdentity) {
return html`
<div class="chat-group assistant">
@ -163,6 +205,25 @@ function isAvatarUrl(value: string): boolean {
);
}
function renderMessageImages(images: ImageBlock[]) {
if (images.length === 0) return nothing;
return html`
<div class="chat-message-images">
${images.map(
(img) => html`
<img
src=${img.url}
alt=${img.alt ?? "Attached image"}
class="chat-message-image"
@click=${() => window.open(img.url, "_blank")}
/>
`,
)}
</div>
`;
}
function renderGroupedMessage(
message: unknown,
opts: { isStreaming: boolean; showReasoning: boolean },
@ -179,6 +240,8 @@ function renderGroupedMessage(
const toolCards = extractToolCards(message);
const hasToolCards = toolCards.length > 0;
const images = extractImages(message);
const hasImages = images.length > 0;
const extractedText = extractTextCached(message);
const extractedThinking =
@ -207,11 +270,12 @@ function renderGroupedMessage(
)}`;
}
if (!markdown && !hasToolCards) return nothing;
if (!markdown && !hasToolCards && !hasImages) return nothing;
return html`
<div class="${bubbleClasses}">
${canCopyMarkdown ? renderCopyAsMarkdownButton(markdown!) : nothing}
${renderMessageImages(images)}
${reasoningMarkdown
? html`<div class="chat-thinking">${unsafeHTML(
toSanitizedMarkdownHtml(reasoningMarkdown),

View File

@ -0,0 +1,99 @@
import { describe, expect, it } from "vitest";
import {
handleChatEvent,
type ChatEventPayload,
type ChatState,
} from "./chat";
function createState(overrides: Partial<ChatState> = {}): ChatState {
return {
client: null,
connected: true,
sessionKey: "main",
chatLoading: false,
chatMessages: [],
chatThinkingLevel: null,
chatSending: false,
chatMessage: "",
chatRunId: null,
chatStream: null,
chatStreamStartedAt: null,
lastError: null,
...overrides,
};
}
describe("handleChatEvent", () => {
it("returns null when payload is missing", () => {
const state = createState();
expect(handleChatEvent(state, undefined)).toBe(null);
});
it("returns null when sessionKey does not match", () => {
const state = createState({ sessionKey: "main" });
const payload: ChatEventPayload = {
runId: "run-1",
sessionKey: "other",
state: "final",
};
expect(handleChatEvent(state, payload)).toBe(null);
});
it("returns null for delta from another run", () => {
const state = createState({
sessionKey: "main",
chatRunId: "run-user",
chatStream: "Hello",
});
const payload: ChatEventPayload = {
runId: "run-announce",
sessionKey: "main",
state: "delta",
message: { role: "assistant", content: [{ type: "text", text: "Done" }] },
};
expect(handleChatEvent(state, payload)).toBe(null);
expect(state.chatRunId).toBe("run-user");
expect(state.chatStream).toBe("Hello");
});
it("returns 'final' for final from another run (e.g. sub-agent announce) without clearing state", () => {
const state = createState({
sessionKey: "main",
chatRunId: "run-user",
chatStream: "Working...",
chatStreamStartedAt: 123,
});
const payload: ChatEventPayload = {
runId: "run-announce",
sessionKey: "main",
state: "final",
message: {
role: "assistant",
content: [{ type: "text", text: "Sub-agent findings" }],
},
};
expect(handleChatEvent(state, payload)).toBe("final");
expect(state.chatRunId).toBe("run-user");
expect(state.chatStream).toBe("Working...");
expect(state.chatStreamStartedAt).toBe(123);
});
it("processes final from own run and clears state", () => {
const state = createState({
sessionKey: "main",
chatRunId: "run-1",
chatStream: "Reply",
chatStreamStartedAt: 100,
});
const payload: ChatEventPayload = {
runId: "run-1",
sessionKey: "main",
state: "final",
};
expect(handleChatEvent(state, payload)).toBe("final");
expect(state.chatRunId).toBe(null);
expect(state.chatStream).toBe(null);
expect(state.chatStreamStartedAt).toBe(null);
});
});

View File

@ -1,6 +1,7 @@
import type { GatewayBrowserClient } from "../gateway";
import { extractText } from "../chat/message-extract";
import type { GatewayBrowserClient } from "../gateway";
import { generateUUID } from "../uuid";
import type { ChatAttachment } from "../ui-types";
export type ChatState = {
client: GatewayBrowserClient | null;
@ -11,6 +12,7 @@ export type ChatState = {
chatThinkingLevel: string | null;
chatSending: boolean;
chatMessage: string;
chatAttachments: ChatAttachment[];
chatRunId: string | null;
chatStream: string | null;
chatStreamStartedAt: number | null;
@ -43,17 +45,44 @@ export async function loadChatHistory(state: ChatState) {
}
}
export async function sendChatMessage(state: ChatState, message: string): Promise<boolean> {
function dataUrlToBase64(dataUrl: string): { content: string; mimeType: string } | null {
const match = /^data:([^;]+);base64,(.+)$/.exec(dataUrl);
if (!match) return null;
return { mimeType: match[1], content: match[2] };
}
export async function sendChatMessage(
state: ChatState,
message: string,
attachments?: ChatAttachment[],
): Promise<boolean> {
if (!state.client || !state.connected) return false;
const msg = message.trim();
if (!msg) return false;
const hasAttachments = attachments && attachments.length > 0;
if (!msg && !hasAttachments) return false;
const now = Date.now();
// Build user message content blocks
const contentBlocks: Array<{ type: string; text?: string; source?: unknown }> = [];
if (msg) {
contentBlocks.push({ type: "text", text: msg });
}
// Add image previews to the message for display
if (hasAttachments) {
for (const att of attachments) {
contentBlocks.push({
type: "image",
source: { type: "base64", media_type: att.mimeType, data: att.dataUrl },
});
}
}
state.chatMessages = [
...state.chatMessages,
{
role: "user",
content: [{ type: "text", text: msg }],
content: contentBlocks,
timestamp: now,
},
];
@ -64,12 +93,29 @@ export async function sendChatMessage(state: ChatState, message: string): Promis
state.chatRunId = runId;
state.chatStream = "";
state.chatStreamStartedAt = now;
// Convert attachments to API format
const apiAttachments = hasAttachments
? attachments
.map((att) => {
const parsed = dataUrlToBase64(att.dataUrl);
if (!parsed) return null;
return {
type: "image",
mimeType: parsed.mimeType,
content: parsed.content,
};
})
.filter((a): a is NonNullable<typeof a> => a !== null)
: undefined;
try {
await state.client.request("chat.send", {
sessionKey: state.sessionKey,
message: msg,
deliver: false,
idempotencyKey: runId,
attachments: apiAttachments,
});
return true;
} catch (err) {
@ -115,8 +161,17 @@ export function handleChatEvent(
) {
if (!payload) return null;
if (payload.sessionKey !== state.sessionKey) return null;
if (payload.runId && state.chatRunId && payload.runId !== state.chatRunId)
// Final from another run (e.g. sub-agent announce): refresh history to show new message.
// See https://github.com/clawdbot/clawdbot/issues/1909
if (
payload.runId &&
state.chatRunId &&
payload.runId !== state.chatRunId
) {
if (payload.state === "final") return "final";
return null;
}
if (payload.state === "delta") {
const next = extractText(payload.message);

View File

@ -1,7 +1,14 @@
export type ChatAttachment = {
id: string;
dataUrl: string;
mimeType: string;
};
export type ChatQueueItem = {
id: string;
text: string;
createdAt: number;
attachments?: ChatAttachment[];
};
export const CRON_CHANNEL_LAST = "last";

View File

@ -1,7 +1,7 @@
import { html, nothing } from "lit";
import { repeat } from "lit/directives/repeat.js";
import type { SessionsListResult } from "../types";
import type { ChatQueueItem } from "../ui-types";
import type { ChatAttachment, ChatQueueItem } from "../ui-types";
import type { ChatItem, MessageGroup } from "../types/chat-types";
import { icons } from "../icons";
import {
@ -52,6 +52,9 @@ export type ChatProps = {
splitRatio?: number;
assistantName: string;
assistantAvatar: string | null;
// Image attachments
attachments?: ChatAttachment[];
onAttachmentsChange?: (attachments: ChatAttachment[]) => void;
// Event handlers
onRefresh: () => void;
onToggleFocusMode: () => void;
@ -95,6 +98,82 @@ function renderCompactionIndicator(status: CompactionIndicatorStatus | null | un
return nothing;
}
function generateAttachmentId(): string {
return `att-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
}
function handlePaste(
e: ClipboardEvent,
props: ChatProps,
) {
const items = e.clipboardData?.items;
if (!items || !props.onAttachmentsChange) return;
const imageItems: DataTransferItem[] = [];
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.type.startsWith("image/")) {
imageItems.push(item);
}
}
if (imageItems.length === 0) return;
e.preventDefault();
for (const item of imageItems) {
const file = item.getAsFile();
if (!file) continue;
const reader = new FileReader();
reader.onload = () => {
const dataUrl = reader.result as string;
const newAttachment: ChatAttachment = {
id: generateAttachmentId(),
dataUrl,
mimeType: file.type,
};
const current = props.attachments ?? [];
props.onAttachmentsChange?.([...current, newAttachment]);
};
reader.readAsDataURL(file);
}
}
function renderAttachmentPreview(props: ChatProps) {
const attachments = props.attachments ?? [];
if (attachments.length === 0) return nothing;
return html`
<div class="chat-attachments">
${attachments.map(
(att) => html`
<div class="chat-attachment">
<img
src=${att.dataUrl}
alt="Attachment preview"
class="chat-attachment__img"
/>
<button
class="chat-attachment__remove"
type="button"
aria-label="Remove attachment"
@click=${() => {
const next = (props.attachments ?? []).filter(
(a) => a.id !== att.id,
);
props.onAttachmentsChange?.(next);
}}
>
${icons.x}
</button>
</div>
`,
)}
</div>
`;
}
export function renderChat(props: ChatProps) {
const canCompose = props.connected;
const isBusy = props.sending || props.stream !== null;
@ -109,8 +188,11 @@ export function renderChat(props: ChatProps) {
avatar: props.assistantAvatar ?? props.assistantAvatarUrl ?? null,
};
const hasAttachments = (props.attachments?.length ?? 0) > 0;
const composePlaceholder = props.connected
? "Message (↩ to send, Shift+↩ for line breaks)"
? hasAttachments
? "Add a message or paste more images..."
: "Message (↩ to send, Shift+↩ for line breaks, paste images)"
: "Connect to the gateway to start chatting…";
const splitRatio = props.splitRatio ?? 0.6;
@ -217,7 +299,12 @@ export function renderChat(props: ChatProps) {
${props.queue.map(
(item) => html`
<div class="chat-queue__item">
<div class="chat-queue__text">${item.text}</div>
<div class="chat-queue__text">
${item.text ||
(item.attachments?.length
? `Image (${item.attachments.length})`
: "")}
</div>
<button
class="btn chat-queue__remove"
type="button"
@ -235,39 +322,43 @@ export function renderChat(props: ChatProps) {
: nothing}
<div class="chat-compose">
<label class="field chat-compose__field">
<span>Message</span>
<textarea
.value=${props.draft}
?disabled=${!props.connected}
@keydown=${(e: KeyboardEvent) => {
if (e.key !== "Enter") return;
if (e.isComposing || e.keyCode === 229) return;
if (e.shiftKey) return; // Allow Shift+Enter for line breaks
if (!props.connected) return;
e.preventDefault();
if (canCompose) props.onSend();
}}
@input=${(e: Event) =>
props.onDraftChange((e.target as HTMLTextAreaElement).value)}
placeholder=${composePlaceholder}
></textarea>
</label>
<div class="chat-compose__actions">
<button
class="btn"
?disabled=${!props.connected || (!canAbort && props.sending)}
@click=${canAbort ? props.onAbort : props.onNewSession}
>
${canAbort ? "Stop" : "New session"}
</button>
<button
class="btn primary"
?disabled=${!props.connected}
@click=${props.onSend}
>
${isBusy ? "Queue" : "Send"}<kbd class="btn-kbd"></kbd>
</button>
${renderAttachmentPreview(props)}
<div class="chat-compose__row">
<label class="field chat-compose__field">
<span>Message</span>
<textarea
.value=${props.draft}
?disabled=${!props.connected}
@keydown=${(e: KeyboardEvent) => {
if (e.key !== "Enter") return;
if (e.isComposing || e.keyCode === 229) return;
if (e.shiftKey) return; // Allow Shift+Enter for line breaks
if (!props.connected) return;
e.preventDefault();
if (canCompose) props.onSend();
}}
@input=${(e: Event) =>
props.onDraftChange((e.target as HTMLTextAreaElement).value)}
@paste=${(e: ClipboardEvent) => handlePaste(e, props)}
placeholder=${composePlaceholder}
></textarea>
</label>
<div class="chat-compose__actions">
<button
class="btn"
?disabled=${!props.connected || (!canAbort && props.sending)}
@click=${canAbort ? props.onAbort : props.onNewSession}
>
${canAbort ? "Stop" : "New session"}
</button>
<button
class="btn primary"
?disabled=${!props.connected}
@click=${props.onSend}
>
${isBusy ? "Queue" : "Send"}<kbd class="btn-kbd"></kbd>
</button>
</div>
</div>
</div>
</section>