# Linetta development guide This guide collects contributor-facing architecture, build, release, and troubleshooting details. For the product overview and downloads, see the [main README](../README.md). ## Architecture - Tauri 2 Rust shell with React 18, Vite, and TypeScript - Embedded Go engine linked through a C ABI - JSON-RPC envelopes as the internal request contract - SQLite under the local Linetta data directory - [`github.com/devlikebear/tars`](https://github.com/devlikebear/tars) for URL fetching and the keyword memory store Cargo links the embedded Go engine through `apps/desktop/src-tauri/build.rs`. ## Desktop development Install dependencies once: ```sh cd apps/desktop pnpm install ``` Start the desktop app: ```sh make dev ``` This wraps `scripts/dev.sh`, which launches `tauri dev`. Build the standalone JSON-RPC engine for debugging: ```sh make build-engine ``` The debug binary is written under `engine/bin/` and is not bundled into the Tauri app. Build the desktop release binary for the current operating system: ```sh make build-desktop ``` ## Verification Run the full local gate: ```sh make test ``` Useful narrower checks: ```sh make test-go make test-desktop make test-tauri make test-mobile-engine ``` `make test` runs Go tests, frontend Vitest tests, the Vite production build, and the Tauri shell's `cargo check` and `cargo test`. `make test-mobile-engine` runs the Go engine suite with the `mobile` build tag so iOS- and Android-safe stubs stay covered. ## Mobile engine development Build mobile engine artifacts directly when the platform toolchains are installed: ```sh make build-mobile-engine-ios make build-mobile-engine-android ``` The iOS target requires Xcode's iOS SDK. The Android target requires `ANDROID_NDK_HOME`. Regenerate the ignored Tauri mobile native projects with: ```sh make mobile-ios-init make mobile-android-init ``` For local iOS simulator smoke testing, install Xcode's matching simulator runtime and run: ```sh make build-mobile-ios-sim make smoke-mobile-ios-sim ``` Open Xcode and complete its license and first-launch setup before running these commands. A license error from `xcrun simctl list devices available` blocks both native builds and simulator verification. The app and embedded engine target iOS/iPadOS 15.0 or newer. Tauri preserves an existing `apps/desktop/src-tauri/gen/apple/project.yml` during initialization. If an older generated project still targets 14.0, back up that file outside the generated directory, then run `make mobile-ios-init` to recreate it. Reapply any local native customizations from the backup before building. Use the Make targets for simulator builds and development: they prepend `scripts/ios-toolchain` to PATH so swift-rs uses SwiftPM's native build system. This avoids Xcode 27's default Swift build system mixing macOS and iOS SDK flags. For direct `pnpm tauri ios build` commands (including device builds), prepend the same absolute directory to PATH. This wrapper only changes `swift build`; compiler information queries still pass through to the selected Xcode toolchain. The smoke script defaults to an available iPhone. Verify iPad separately by choosing its UDID from `xcrun simctl list devices available`: ```sh LINETTA_IOS_SIM_UDID="" make smoke-mobile-ios-sim make dev-mobile-ios IOS_SIM="" ``` The shared mobile CSS covers narrow desktop windows and touch screens up to 1366 CSS pixels, including landscape phones. The iPad rules override it at 701–1366 pixels wide and at least 600 pixels tall. Keep `MOBILE_WORKSPACE_QUERY` in `useSizeClass.ts` aligned with the shared media query in `App.css`, so outline state and rendered controls agree. Verify portrait and landscape, opening the outline and inspectors, and software-keyboard transitions on both device types. Browser layout checks do not replace the native engine smoke or device input QA. For local Android smoke testing, install the Android SDK and NDK and run: ```sh make build-mobile-android-debug make build-mobile-android-release-smoke ``` The iOS simulator target creates a no-sign `.app`, links the embedded Go engine, installs and launches it, verifies engine symbols, and checks that `library.db` is created. The Android release smoke target builds APK and AAB artifacts, verifies packaged native libraries and signing, and avoids using real upload credentials. Signed iOS export is handled by the manual mobile release workflow because it depends on Apple team and signing credentials. ## MCP tools Linetta hosts an MCP server inside the running app, so an external client — Claude Code, Claude Desktop — can work on the manuscript. It is off until the writer consents and enables it under **Settings → Connect an external agent**, binds `127.0.0.1` only, and requires a locally generated bearer token. The default tool set has 29 tools (14 read, 15 write). Optional memory, skills, and visual-write groups can be switched off in Settings → Tool budget. Write tools appear only in `full` mode, so `read_only` omits them from `tools/list` entirely rather than refusing them at call time. Read: - `linetta_list_works`, `linetta_get_outline`, `linetta_get_story_context` - `linetta_read_scene`, `linetta_list_characters`, `linetta_get_fact_cards` - `linetta_get_character_visuals`, `linetta_build_illustration_prompt` - `linetta_analyze_style`: holds a scene, a chapter or the whole work against the writer's style rules (`internal/style`) and reports violations. It is deterministic and model-free; tone is left to the caller. The same call returns `stats` — counted measurements of the scenes read (`style.Measure`) - `linetta_search_manuscript`, `linetta_where_does_appear`, `linetta_get_plot` - `linetta_read_skill` - `linetta_publish_status`: each episode's state on Linetta Works (see [Publishing](#publishing-to-linetta-works) below). Answers locally, without contacting the site, when no account is connected `linetta_read_scene` returns plain prose by default — the form every write tool takes back. `format: "markdown"` returns the scene through `export.DocToMarkdown` instead, for carrying it out of Linetta with its formatting; it is not a form to write back. Write (`full` mode only): - `linetta_write_scene`: requires the `content_version` from a prior read, so a scene the writer edited meanwhile is never silently overwritten; - `linetta_revise_scene`: exact-string replacement with a `dry_run` preview; - `linetta_write_summary`, `linetta_apply_story_ops`; - `linetta_create_checkpoint`, `linetta_undo_last_change`; - `linetta_create_work`, `linetta_edit_memory`, `linetta_edit_skill`; - `linetta_propose_style_profile`: saves an agent's description of the writer's style as a pending draft (`project_style_drafts`). Only the app's `style.approve_draft` RPC moves a draft into `projects.style_notes`, and so into the story brief; there is no MCP tool that approves; - `linetta_set_character_visuals`, `linetta_set_art_style`; - `linetta_add_reference_image`, `linetta_delete_reference_image`; - `linetta_publish_episode`: publishes one episode, or updates its text on the site, through the same `publish.Service` the app's publish dialog uses. Refused until the writer has connected an account and registered the work in the app — there is deliberately no tool for either. Visual editing: `linetta_set_character_visuals`, `linetta_set_art_style`, `linetta_add_reference_image`, `linetta_delete_reference_image` (full mode only). Each returns an `undo_batch_id` for `linetta_undo_last_change` or the agent undo button. The optional `visual_write_tools_disabled` setting omits this four-tool group. Undo retains up to eight changes in memory, including deleted image bytes and metadata; restarting clears the window. Restoring an image still enforces the eight-image limit. Scene writes are snapshotted, structural and visual changes return undo batch IDs. Every tool call is recorded in the activity log and rate limited inside the registration decorator — so a new tool cannot be added without inheriting the limit. `web_fetch` (from TARS `pkg/tools`) is still used by the Fact Book to capture a source URL. It needs no key, and it is not exposed as an MCP tool. Since 1.2 the engine does link a language-model client, for the built-in agent. `scripts/validate-story-core-deps.sh` bounds where: `tars/pkg/agentloop` and `pkg/session` must not appear anywhere in the engine — the agent's loop is ours, in `internal/agent` — and `tars/pkg/llm` may be imported only by `internal/provider`, `internal/agent`, and the test-only `internal/agenttest`. The story core (`storycontext`, `storyops`, `mcphost`, `rpc/handlers`) must not reach it even transitively: those packages are shared by every agent, built-in or connected over MCP, and a model client in their dependency graph would mean one of them could call a model on its own. ## Versioning and builds Keep all app version surfaces aligned with: ```sh make bump-version VERSION=0.2.0 ``` This updates the desktop `package.json`, Tauri configuration, Cargo metadata, the relevant lockfile entry, and embedded engine diagnostics version. GitHub Actions workflows: - `.github/workflows/ci.yml`: runs `make test` on pull requests and pushes to `main`; - `.github/workflows/build.yml`: builds desktop artifacts on manual dispatch and `v*` tags; - `.github/workflows/mobile-engine.yml`: verifies the mobile-tagged engine and uploads iOS and Android engine artifacts; - `.github/workflows/mobile-release.yml`: builds signed Tauri mobile artifacts and can upload them to an existing GitHub release. ## Mac App Store release The Mac App Store build is sandboxed and built, signed and uploaded from a maintainer's Mac; no workflow does it. Credentials, certificates and the provisioning profile live in `~/.linetta/apple/` and are never committed. ### One-time setup 1. `~/.linetta/apple/config.env` defines the App Store Connect API key: `APP_STORE_CONNECT_KEY_ID`, `APP_STORE_CONNECT_ISSUER_ID`, `APPLE_TEAM_ID` and `AUTH_KEY_PATH` (the `.p8` file beside it). 2. `bash scripts/setup-mas-apple.sh` registers the bundle id, creates the Mac App Distribution and Mac Installer Distribution certificates, and writes `linetta-mas.provisionprofile`. It creates real Apple resources and Apple caps distribution certificates per account, so run it once; the script's header describes the manual fallback when the API refuses a certificate. 3. Append the identities `security find-identity -v -p codesigning` reports, and the profile path, to `config.env`: ```sh MAS_APP_IDENTITY="Apple Distribution: ()" MAS_INSTALLER_IDENTITY="Mac Installer Distribution: ()" MAS_PROFILE_PATH="$HOME/.linetta/apple/linetta-mas.provisionprofile" ``` 4. In App Store Connect, create the macOS app record for bundle id `com.devlikebear.linetta`. An upload is matched to it by bundle id. ### Each release Build, sign and package the installer: ```sh make release-mas-local ``` The package lands at `apps/desktop/src-tauri/target/release/bundle/macos/Linetta.pkg`. Then upload it: ```sh mkdir -p "$HOME/.appstoreconnect/private_keys" cp "$HOME/.linetta/apple/AuthKey_.p8" "$HOME/.appstoreconnect/private_keys/" # once: altool looks here set -a; . "$HOME/.linetta/apple/config.env"; set +a PKG="apps/desktop/src-tauri/target/release/bundle/macos/Linetta.pkg" xcrun altool --validate-app -f "${PKG}" -t macos \ --apiKey "${APP_STORE_CONNECT_KEY_ID}" --apiIssuer "${APP_STORE_CONNECT_ISSUER_ID}" xcrun altool --upload-app -f "${PKG}" -t macos \ --apiKey "${APP_STORE_CONNECT_KEY_ID}" --apiIssuer "${APP_STORE_CONNECT_ISSUER_ID}" ``` - Fix any asset or entitlement error validation reports and rebuild before uploading. - If altool is unavailable, upload the same `.pkg` with Transporter.app using the same API key. - The upload is done when the build appears in App Store Connect under TestFlight → Builds, first as "Processing". ## Troubleshooting - **Engine startup failure:** use the desktop diagnostic screen to retry and copy diagnostics. - **Embedded engine link failure:** run `cd apps/desktop/src-tauri && cargo build` and inspect the Go archive output from `build.rs`. - **MCP client cannot connect:** confirm the server is on in Settings, that the port is not taken by something else, and that the token in your client config matches the current one. - **Backup or Git sync failure:** open Settings and inspect the operation status card for the latest error and timestamp. ## Publishing to Linetta Works The desktop app can publish episodes to [Linetta Works](https://works.marvin-42.com). The wire contract is the site's: `docs/api.md` in the linetta-artworks repository, `api_version` 1. Follow it rather than adjusting the client to whatever the server happens to do; a contract change is made there first. - `internal/episode` decides what an episode is (the same rules as `apps/desktop/src/lib/outlineEpisode.ts`, which still has its own copy), and `export.ExportEpisodeMarkdown` renders one for readers: scenes joined by `* * *`, mentions without `@`, note anchors dropped. The content hash is the SHA-256 of that Markdown, which the site recomputes. - `internal/publish` holds the HTTP client, the device-pairing flow (`Connector`, shaped like `codexauth.Service`), and `Service`, which merges the local episode list with the site's and publishes. `publish_works` and `publish_episodes` (migration 0023) are a local copy of what the site said, for offline display. - The token lives in the secret store as `publish.token`, or in `/publish-token` (0600) where there is none (Linux); the account's non-secret details are `settings.json`'s `publish_account`. - `publish.*` RPCs are registered by `engineapp/publish_enabled.go`; the `mobile` build registers refusing stubs (`publish_disabled.go`) and reports `publish_available: false`, which hides the UI. - Nothing reaches the site before an account is connected: `TestPublishMethodsAreRegisteredAndSendNothingUntilConnected` holds that. For development against a local server, point the engine elsewhere: ```sh LINETTA_PUBLISH_BASE_URL=http://127.0.0.1:8090 make dev ``` The shell only opens `https://works.marvin-42.com` in the browser (`EXTERNAL_URL_HOSTS`), so against a local server open the connect URL the Settings pane shows by hand. ## Plan documents `docs/plans//` holds work orders for work that is still in progress: a `roadmap.md` and one document per phase. When the work has shipped, delete its folder in the same pull request that finishes it, or the next one. Git history keeps every plan, and what is still worth knowing afterwards belongs in this guide or the README, where it stays current. Logs, audit output and one-off reports do not go in the repository.