Read this first for fast orientation. For full rationale, see docs/design-doc.md; for workflow, see CONTRIBUTING.md.
Threadwire is an open-source, native (Swift/Kotlin) chat SDK for iOS and Android that embeds LLM-powered conversations into existing apps. It is a UI + transport library only — it never talks to LLM providers directly, never executes sensitive actions, and never runs its own messaging infrastructure. Everything business-specific is delegated to the integrator's own HTTP backend (a BFF).
- UI is always native: SwiftUI (iOS) / Jetpack Compose (Android). Never a JS bridge, never a cross-platform canvas.
- The library only talks to the host-configured
baseUrl(the integrator's BFF) — never an LLM provider directly, never stores a provider API key. - Card action taps only call
ChatActionHandler.handle(actionId, payload)on the host. The library makes no network call itself when an action is tapped. - No bundled telemetry vendor — only the
ChatTelemetrySinkcontract. - Kotlin Multiplatform (KMP) is scoped to
commonMainlogic (transport, state, parsing) — never UI. - Business context injected by the host (headers/body) is opaque — never parsed or interpreted by the library.
- Secondary config (e.g. remote UI theming) degrades gracefully to cache, then to a bundled default, on network failure. Feature flags (upload/audio/transcription) default to
false/off on failure instead — see design doc §12.1. - Prefer aligning with an existing market convention (wire protocol, card schema) over inventing a new one from scratch.
Native UI (SwiftUI / Jetpack Compose) — never shared
│ observes StateFlow / calls methods
commonMain (KMP) — ChatSession (state machine), ChatTransport (SSE ⇄ WebSocket),
ChatContextProvider, ChatActionHandler, ChatTelemetrySink,
card schema parser, ChatUIConfig
│ HTTPS (SSE / WebSocket)
Integrator's BFF — out of scope; owns LLM choice, context, handoff routing, action execution
- Transport: SSE by default (AI phase), promotes to WebSocket only once
handoff-startfires (human handoff phase), demotes back to SSE onhandoff-end. Wire protocol is SSE with typed start/delta/end parts, ID-reconciled (Vercel AI SDK-inspired) plus Anthropic-style indexed content blocks. See design doc §4 and §6. - Cards: own minimal schema (not Adaptive Cards), versioned, rendered 100% natively. The LLM never generates card JSON directly — it calls small business-level tools, and only the BFF translates those into card JSON. See design doc §8.
- Handoff: bidirectional cycle (
ai_active ⇄ handoff_pending ⇄ handoff_active), not a one-way escalation. See design doc §6.1.
:core pure KMP, no UI — transport, context, state machine, card schema, telemetry
(exists: protocol/ (M0, event parsing), transport/ (M0, SSE transport),
session/ (M1, ChatSession/ChatContextProvider/ChatConfig/ChatState).
Cards (M4), telemetry (M5), WebSocket handoff (M6) not built yet)
:ui-android Jetpack Compose, consumes :core (M2 - ChatScreen, bubbles, streaming
markdown via mikepenz, interop ChatView/ChatFragment)
:ui-ios SwiftUI via a local SPM package at ui-ios/ (M2 - ChatView, bubbles,
streaming markdown via microsoft/SwiftStreamingMarkdown, interop
ChatViewController). iOS 16+ floor (SwiftStreamingMarkdown's own
requirement, not :core's or UIHostingController's).
:sample-app-android / sample-app-ios
consume :ui-android/:ui-ios (not :core directly) - never privileged
access to :core/:ui-* internals, the same way any third-party
integrator would
:tools:fake-sse-server
dev-only local Ktor server for manually testing SseChatTransport
(scripted event sequence + deliberate mid-stream drop) - never a
dependency of :core, not part of what gets published
:core produces a real combined ThreadwireCore.xcframework via the Kotlin Multiplatform XCFramework Gradle DSL (task assembleThreadwireCoreXCFramework, output at core/build/XCFrameworks/<debug|release>/) - :ui-ios/Package.swift references it as a local binaryTarget. sample-app-ios separately still embeds :core via its own direct Xcode Run Script (./gradlew :core:embedAndSignAppleFrameworkForXcode) - having it wired in two places is a known, deliberate loose end from M2, not yet cleaned up. Adding ui-ios as a local SPM dependency to sample-app-ios.xcodeproj is a manual Xcode step (File → Add Package Dependencies → Add Local...) - never hand-edit .pbxproj for this.
:core's transport/protocol layer (com.fsk.threadwire.protocol.ChatEvent/ChatEventParser, com.fsk.threadwire.transport.ChatTransport/SseChatTransport) stays decoupled from the session layer - ChatTransport.streamEvents takes a TransportRequest (url/headers/body, renamed from ChatRequest in M1 to avoid colliding with the new session-level type below). com.fsk.threadwire.session (M1) adds: ChatRequest (minimal, pre-headers/context view handed to ChatContextProvider - not the same type as TransportRequest), ChatContextProvider/ChatConfig (design doc §7 - ChatConfig intentionally omits actionHandler/telemetrySink until M4/M5 exist), ChatState/ChatMessage/MessagePart/SessionPhase (the state machine's data shape - not specified in the design doc beyond "StateFlow<ChatState>", designed from scratch), ChatStateReducer (pure fold, ID-reconciliation of repeated parts lives here), and ChatSession (ties it together, one turn per sendMessage(text: String) call). ChatSession depends on the ChatTransport interface, never SseChatTransport directly, so M6 can later substitute a phase-aware SSE/WebSocket router without a breaking change. SessionPhase already models the full handoff cycle (AiActive/HandoffPending/HandoffActive) reacting to M0's already-parsed handoff events, but the transport underneath stays SSE-only until M6 actually builds WebSocket support.
- Commits: Conventional Commits (
feat:,fix:,docs:,chore:,refactor:,test:,build:,ci:), optionally scoped (feat(core): ...). - Branches: prefixed to match commit type (
feat/,fix/,docs/,chore/,refactor/,test/,build/,ci/). - Code and docs are written in English; project discussion with the maintainer may happen in Portuguese.
dev is the default/integration branch — branch off it, PR back into it (squash merge). main only moves via a release PR from dev (merge commit, not squash) and is what will trigger CI/CD artifact/version publishing once that exists. Both branches are protected on GitHub: no direct pushes, not even for admins. See CONTRIBUTING.md for the full workflow.
Design phase is complete (design doc v0.1). M0 (SSE transport), M1 (session state machine), and M2 (native UI: :ui-android/:ui-ios, bubbles, streaming markdown) are implemented (see Module layout above) - pending the maintainer's own build/manual validation before merge. M2 particularly depends on manual validation: the §13.1 UIKit/View-system interop concern (safe-area, keyboard avoidance, navigation-stack integration for ChatViewController/ChatFragment) can only be confirmed by running the app, not by the code compiling. Roadmap order: M0 SSE transport → M1 session/context → M2 native text UI → M3 media → M4 cards → M5 telemetry → M6 WebSocket handoff → M7 sample apps. Check README.md#roadmap and recent commits/issues before assuming any milestone is further along than it is.
If a task touches a part of the design that isn't fully specified yet (naming, exact schema fields, which optional pieces to build), stop and ask the maintainer rather than assuming a default — this project treats that as a hard rule, not a suggestion.
An AI agent working on this repo may open PRs (gh pr create or equivalent), but must never merge a PR — not even one it opened itself — unless explicitly told to merge that specific PR in that specific moment. The maintainer always reviews and merges PRs himself. A past approval to merge one PR does not carry over to the next one; treat every merge as needing fresh, explicit permission.
Likewise, don't proactively run lint, test suites, or build/run the app "to verify" a change unless asked to in the moment. The maintainer prefers to do that validation himself — describe what changed and what to check instead of spending effort re-deriving it.
The repo auto-deletes head branches on merge (GitHub setting), so there's no remote branch to clean up after a merge. When told a PR was merged, sync local state: git checkout dev && git pull && git fetch --prune && git branch -d <merged-branch>.