DEV Community

Yuuichi Eguchi
Yuuichi Eguchi

Posted on

What it takes to embed libghostty in a Swift app: six months of lessons

Six months ago I started Calyx, a native macOS terminal for running and supervising coding agents, on top of libghostty, the embeddable core of Ghostty (1.3.1 as I write this). Since then I have tagged 103 releases, and every one of them since v0.20.4 in mid-April runs on the same libghostty build. Almost all of the work happened above the FFI line.

This post is the list of things I wish someone had written down before I started. It is not a tutorial. It is the set of surprises, in the order I would have wanted to hear about them.

1. The build is easy. The coupling is not.

Producing the framework is one command inside the ghostty checkout:

zig build -Demit-xcframework=true -Dxcframework-target=native \
  -Demit-macos-app=false -Doptimize=ReleaseFast
Enter fullscreen mode Exit fullscreen mode

One of those flags, and the toolchain that runs the command, cost me real time.

-Doptimize=ReleaseFast was missing from my build recipe for the first month. Nothing broke. The terminal simply rendered heavy workloads, like image previews in yazi, noticeably slower than stock Ghostty.app. A user reported it. Rebuilding with ReleaseFast took libghostty_zcu.o from 33 MB to 8.9 MB and removed every debug panic helper. A missing optimizer flag in a build script is a performance bug that no test catches.

The Zig version is pinned by ghostty, not by you. build.zig.zon declares minimum_zig_version, and every ghostty update can move it. I keep several Zig versions installed side by side and assert the expected one in the update script. Treat the Zig toolchain as part of the submodule, not as part of your machine.

Three more build facts that are worth knowing up front:

  • The xcframework you get with -Dxcframework-target=native is arm64 only. The static archive is about 141 MB in ReleaseFast. I check it into the repo through git-lfs so that building Calyx never requires Zig, and the release build passes ARCHS=arm64 explicitly. Calyx is Apple Silicon only as a direct consequence.
  • Xcode 26 no longer ships a metallib binary, and ghostty's build looks for one. The fix is a two-line script on PATH: exec xcrun metal "$@".
  • Treat the ghostty/ submodule as read-only. I corrupted my repository twice by running git commands inside it. Now the update procedure has a human fetch the new tag, and every automated step refuses to touch that directory.

2. The C API is small. The runtime config is the whole contract.

libghostty exposes an app handle, a surface handle, a config handle, and one struct that defines how your host behaves: ghostty_runtime_config_s. It has userdata, a boolean for selection clipboard support, and six callbacks. In Swift:

var runtimeConfig: ghostty_runtime_config_s = ghostty_runtime_config_s(
    userdata: Unmanaged.passUnretained(self).toOpaque(),
    supports_selection_clipboard: false,
    wakeup_cb: ghosttyWakeupCallback,
    action_cb: ghosttyActionCallback,
    read_clipboard_cb: ghosttyReadClipboardCallback,
    confirm_read_clipboard_cb: ghosttyConfirmReadClipboardCallback,
    write_clipboard_cb: ghosttyWriteClipboardCallback,
    close_surface_cb: ghosttyCloseSurfaceCallback
)
Enter fullscreen mode Exit fullscreen mode

Implement all six. The action callback alone carries 65 distinct action tags in the current header, from set_title to progress_report, so it is effectively the event bus of your application.

Two structural decisions I would repeat:

  • Every C call lives in one file. Calyx has a GhosttyFFI enum used as a namespace, 417 lines of thin static wrappers, and nothing else in the app calls ghostty_* functions directly (with two functions I still call from the app delegate and owe myself a refactor for). When the header changes, one file changes.
  • Do not use a bridging header. The xcframework ships a module map, so import GhosttyKit is enough. If you also import ghostty.h from a bridging header you get error: redefinition of module 'GhosttyKit'. The one-line bridging header still sitting in my repo, no longer wired into the build, is a fossil from learning that.

3. Swift 6 strict concurrency meets C callbacks

Calyx is Swift 6.2 with strict concurrency, and every stateful type in the bridge is @MainActor (the FFI namespace, the pure event translators, and the Metal layer subclass are nonisolated). C callbacks do not know what an actor is, so the boundary needs a few idioms.

The callbacks themselves are file-level functions, never closures and never methods, so they can be passed as @convention(c) pointers. userdata carries the host object across the boundary both ways with Unmanaged:

static func appController(from userdata: UnsafeMutableRawPointer) -> GhosttyAppController {
    Unmanaged<GhosttyAppController>.fromOpaque(userdata).takeUnretainedValue()
}
Enter fullscreen mode Exit fullscreen mode

The interesting part is that the two important callbacks arrive differently, so they need different hops.

The wakeup callback is called from an arbitrary thread and returns nothing, so it hops asynchronously:

// Simplified from the source.
private func ghosttyWakeupCallback(_ userdata: UnsafeMutableRawPointer?) {
    guard let userdata else { return }
    nonisolated(unsafe) let ud = userdata
    DispatchQueue.main.async {
        GhosttyAppController.appController(from: ud).tick()
    }
}
Enter fullscreen mode Exit fullscreen mode

The action callback is called on the main thread during tick() and must return a Bool, so it cannot be dispatched. It enters the main actor synchronously:

private func ghosttyActionCallback(_ app: ghostty_app_t?, _ target: ghostty_target_s,
                                   _ action: ghostty_action_s) -> Bool {
    guard let app else { return false }
    nonisolated(unsafe) let safeApp = app
    let safeTarget = target
    let safeAction = action
    return MainActor.assumeIsolated {
        GhosttyActionRouter.handleAction(app: safeApp, target: safeTarget, action: safeAction)
    }
}
Enter fullscreen mode Exit fullscreen mode

That asymmetry (async for a void callback, assumeIsolated for one with a return value) is the concrete lesson. Everything else follows from it:

  • In the bridge, nonisolated(unsafe) is confined to C pointer bindings and to the handles you must free in deinit, because deinit is nonisolated. I wrote that rule down after I caught myself using it as a general escape hatch elsewhere in the app, where a few uses still remain.
  • @preconcurrency import AppKit where an AppKit protocol conformance needs it. NSTextInputClient on the surface view is the one that forced it.
  • Copy C strings into Swift strings inside the callback. The memory is not yours after you return.
  • Keep C types out of NotificationCenter userInfo dictionaries. Calyx converts enums to Swift values before posting. The one exception is the clipboard read confirmation, which has to carry libghostty's request pointer to the window controller that presents the sheet and completes the request later; the plain clipboard read completes in place inside the callback.

4. The action callback's return value is not a formality

Calyx fans every action out through NotificationCenter under a com.calyx.ghostty.* prefix, 37 names so far, so that windows, tabs, the agent sidebar, and the session layer can subscribe without the bridge knowing about them. That part is ordinary. The part that bit me is the Bool you return.

Returning false tells libghostty that the action was not performed. For a keybind declared with the performable: prefix, libghostty then acts as though no binding existed and the raw key sequence reaches the shell; for an ordinary keybind the event stays consumed either way. So for actions you deliberately do not implement, the return value depends on why:

// Simplified: the source has one case per action, each with its reason.
// GTK-only or touch-only actions: consumed on purpose, nothing to do.
case GHOSTTY_ACTION_TOGGLE_TAB_OVERVIEW,
     GHOSTTY_ACTION_SHOW_GTK_INSPECTOR,
     GHOSTTY_ACTION_SHOW_ON_SCREEN_KEYBOARD:
    return true
Enter fullscreen mode Exit fullscreen mode

Of the 65 tags in the header, Calyx handles most, returns false for 11 it has not implemented (8 listed explicitly and 3 that fall through to the default case), returns true for 3 that only make sense on GTK or touch platforms, and still has 2 stubs (key sequences and mouse-over-link). Keep that table honest in your own router. A true on an unimplemented action silently eats a performable keybind.

Two timing traps in the same callback:

  • GHOSTTY_ACTION_INITIAL_SIZE and CELL_SIZE fire synchronously from inside ghostty_surface_new, before your window has registered any observers for its first surface. Calyx writes those values straight onto the view as properties and reads them back after the constructor returns (the cell size is also posted as a notification for the subscribers that exist later).
  • The close_surface callback can re-enter your own teardown path synchronously. Any "should this kill the process" decision that lives in that path needs to be reentrancy-safe. I moved mine into a separate policy object after the second time it surprised me.

5. Rendering under Liquid Glass

libghostty renders with Metal into a view you hand it:

mutableConfig.platform_tag = GHOSTTY_PLATFORM_MACOS
mutableConfig.platform.macos = ghostty_platform_macos_s(
    nsview: Unmanaged.passUnretained(surfaceView).toOpaque()
)
Enter fullscreen mode Exit fullscreen mode

The engine creates its own Metal device, command queues, and pipeline. Your CAMetalLayer only provides the drawable. Three settings on that layer matter: displaySyncEnabled = true (ghostty drives its own vsync through CVDisplayLink), pixelFormat = .bgra8Unorm, and isOpaque = false. That last one is the whole Liquid Glass story. Calyx does not tint the terminal with glass. The window is isOpaque = false with a clear background, the SwiftUI root applies a .glassEffect sheet, and the terminal is a transparent Metal layer sitting on top of it. The glass is behind the text, not in front of it.

Two things follow from that:

  • Ghostty's background-opacity only affects the default background. Cells that a TUI paints itself (status bars, panels) stay opaque unless you also set background-opacity-cells. Calyx exposes that as a switch.
  • Reduce Transparency has to be honored at every layer, not just the root: the root background becomes the window color, the atmosphere gradient renders nothing, and the terminal host layer becomes opaque. It took three separate branches to make the window fully opaque.

One SwiftUI lesson that has nothing to do with Metal and everything to do with hosting a live PTY: never let the terminal subtree sit inside an if/else. SwiftUI's _ConditionalContent tears down and rebuilds whichever branch was active every time the condition flips, and "rebuild" means destroying and recreating live terminal surfaces. The root view shape in Calyx is fixed, and the conditions live in modifiers.

Scrolling is also yours. libghostty reports total, offset, and len through GHOSTTY_ACTION_SCROLLBAR. Calyx puts an NSScrollView with an empty document view on top of the surface, sized to the scrollback, and overrides hitTest so only the scroller itself intercepts clicks. Smooth trackpad scrolling then has to mirror ghostty's own scroll accumulator to compute the sub-row pixel offset, and mouse events have to be compensated for that offset. The relevant file in ghostty is Surface.zig; I keep the line numbers in a comment because I have had to re-read it more than once.

6. Config: libghostty loads it, you watch it

ghostty_config_load_default_files reads ~/.config/ghostty/config for you, ghostty_config_load_recursive_files follows its config-file includes, and ghostty_config_open_path tells you which file it used. Reading the user's config is free. Two things are not.

Hot reload is entirely the host's job. There is no file watcher in libghostty. Calyx uses DispatchSourceFileSystemObject on the config path and its parent directory (so that editors which replace the file are caught), debounces into a reload coordinator with a monotonic generation counter, and keeps the last-known-good config when a reload fails to parse. It does not yet watch included files.

Overriding is done by load order, not by editing. Calyx loads the user's files first, then its own glass preset from ~/.config/calyx/, then a runtime file that mirrors the Settings sliders, and only then calls ghostty_config_finalize. Earlier versions wrote managed blocks into the user's ghostty config. That was a mistake, and the current version carries a cleanup routine that removes those blocks if it finds them. Do not write into a file the user thinks of as theirs.

The list of keys Calyx overrides is a single array in code, rendered verbatim in the Settings window and asserted by tests, so the UI cannot drift from the behavior. Today it is background-opacity, background-blur, background-opacity-cells, font-codepoint-map, and, for non-Ghostty color presets, foreground (computed from the luminance of the chrome tint so text stays legible against glass).

Keybinds go both directions. A Ghostty keybind action reaches your action router like anything else, and ghostty_surface_binding_action lets you invoke a keybind action by its string name from the host. Global keybinds need a CGEvent tap, gated on ghostty_app_has_global_keybinds.

7. The terminal tells you almost enough

This is the section I would have paid for. libghostty surfaces a lot of terminal protocol as actions, and each one has a catch.

OSC 7 (working directory) arrives as GHOSTTY_ACTION_PWD. It is the right source for "open a new split in the same directory", and it is also the only reliable cwd signal an agent sidebar can get without hooks of your own.

Desktop notifications (OSC 9 and OSC 777) arrive with a title and body. Decode them with String(validatingCString:) so invalid UTF-8 degrades to an empty string instead of trapping, and sanitize bidi override characters and C0/C1 controls before the text reaches UNUserNotificationCenter. Calyx also suppresses the banner when the tab is active and the window is key, and bounces the Dock only for the first unread.

Progress reports (OSC 9;4) arrive as a struct with a state (remove, set, error, indeterminate, pause) and an int8_t percentage, or -1 when none was reported. Calyx currently collapses that to a boolean for the agent status view and does not read the percentage yet.

COMMAND_FINISHED only fires when the shell has ghostty's shell integration loaded. libghostty resolves its resources directory once at engine init, from GHOSTTY_RESOURCES_DIR if it is set and otherwise by climbing from the running executable looking for a terminfo sentinel, and then injects the integration scripts into the shells it spawns. Two things follow for an embedder. Bundle the terminfo and shell-integration directories where that search finds them, or set the variable yourself before the engine initializes; Calyx now does both, and overwrites an inherited value on purpose, because the bundled scripts are version-matched to the embedded engine while a standalone Ghostty's are not. And any shell you spawn yourself gets no injection at all. Calyx's persistent-session daemon spawns its own shells, so it has to forward the variable and source the integration itself; until I did that in July, commands in persistent sessions never produced this action, and nothing told me why. Once it fires there is a second catch: a command whose OSC 133;D carried no exit code still arrives as 0, because ghostty reads the option with a default of zero before building the action, so success and "unknown" are indistinguishable. Calyx keeps its own zsh and fish hooks for the command log and uses the action only as a fallback signal for the shells those hooks do not cover.

SHOW_CHILD_EXITED on macOS gave me an exit_code of 0 every time in testing; only the duration field (spelled timetime_ms in the header) was usable. When you need to know whether a process really died, ask something that knows.

Reading text back through ghostty_surface_read_text takes a selection with a coordinate tag and an exact, top_left, or bottom_right mode. In the current embedded API an exact y coordinate is clamped to the active grid's row count, not the history-inclusive total, so you cannot address a specific row deep in scrollback. What works: ask for the whole screen region with top_left and bottom_right, then slice in Swift. It is a full dump every time, and for a captured command output that is acceptable. A row-ranged read would be the right upstream addition.

Cursor click-to-move is implemented in ghostty as arrow-key steps over cells, so on lines of full-width text (Japanese, for example) the landing position can be off. I document it rather than fight it.

Mouse-over-link is still a stub in Calyx. Cmd-click opening works through the OPEN_URL action; the hover status label does not exist yet. I mention it because it is easy to imply more coverage than you have.

8. Persistent sessions: the PTY moves out, the engine stays

Calyx's opt-in persistent sessions survive app quit and crashes, and they can live on SSH hosts. The design that made this work with libghostty is simple to state: the shell's PTY lives in a small Rust daemon, and libghostty renders a client process that attaches to it. The seam is one field in the surface config:

return command.withCString { cmdCStr in
    var mutableConfig = config
    mutableConfig.command = cmdCStr
    // ...
Enter fullscreen mode Exit fullscreen mode

Instead of the user's shell, the surface runs calyx-session attach --create. libghostty still owns a PTY, the one running the attach client, and the daemon owns the real one. The host also passes the already-laid-out grid size to the attach command, otherwise the embedded engine's bootstrap grid becomes the new shell's initial PTY size.

Two things about this were not obvious.

The daemon embeds ghostty too. To replay a session's screen state to a reattaching client, the daemon keeps a VT terminal per session, and that terminal is ghostty's own VT engine, built as a static library through a tiny Zig shim that path-depends on the same submodule. One submodule, two consumers, two toolchains: an arm64 xcframework for the app and a per-target static library for the Rust daemon, including a musl target for remote payloads.

The daemon cannot be a child of the app. A double-forked daemon inherits the spawning app's jetsam coalition, and once the app quits, the daemon's process context can no longer reach opendirectoryd. From that point getpwuid fails for every shell it spawns, and users lose their username: %n in zsh, $USER, $LOGNAME. The fix is a launchd LaunchAgent, loaded on demand, with NumberOfFiles raised to 4096 because the default soft limit of 256 is not enough for a daemon holding a PTY master, a wake pipe, and a history fd per session. And launchd's job environment is minimal: HOME, PATH, SHELL, USER, and a few XPC variables, but no TERM, no LANG, and no GHOSTTY_*, so the attach path has to re-supply all of that per session.

9. Shipping it

Three release-engineering lessons, all of which cost a broken release.

Sparkle must be re-signed inside out. Flat re-signing of the framework binary passed codesign --verify and failed Gatekeeper. The sequence that works is XPC services, then nested apps, then standalone executables, then the framework, then the outer app again, because re-signing anything inside invalidates the outer seal. The release script also whitelists the framework's root entries and fails on anything unexpected.

Cargo binaries are only ad-hoc signed. The outer app's signature does not re-sign nested Mach-O files, so the Rust daemon needs an explicit Developer ID signature with hardened runtime and a timestamp, or notarization rejects the bundle. The Linux payloads for remote hosts are ELF; codesign cannot sign them and seals them as plain resources, which is fine.

Re-zip after stapling, then sign the zip. Notarize the zip, staple the ticket to the app, create the final zip again, and only then run Sparkle's sign_update. The EdDSA signature must describe the artifact people download.

And one that I learned from Homebrew's source, not from my own. ditto -c -k --keepParent stores extended attributes as AppleDouble ._ entries next to each file. Xcode-built binaries carry com.apple.provenance, so my zips contain 169 of them. Extract that zip with Info-ZIP's unzip and the ._ files land inside the bundle, the resource seal fails, and Gatekeeper calls the app damaged. Extract it with ditto -x -k and everything is fine. Homebrew checks zipinfo for ._ entries and switches to ditto when it finds them, which is the only reason brew install --cask calyx works. If you distribute zips, know how your users extract them.

10. What I would tell you before you start

  • Pin the Zig toolchain to ghostty's minimum_zig_version and keep old versions around. Assert it in your update script.
  • Build with -Doptimize=ReleaseFast from day one and check the archive size.
  • Check the xcframework into LFS so contributors and CI never need Zig.
  • Centralize every C call in one file. Import the module; do not use a bridging header.
  • Make the bridge @MainActor, hop wakeups asynchronously, enter actions with assumeIsolated, and confine nonisolated(unsafe) to pointer bindings and deinit.
  • Return false from the action callback only when you really want a performable keybind to reach the shell.
  • Expect INITIAL_SIZE and CELL_SIZE before your observers exist.
  • Bundle ghostty's resources where its search finds them, or set GHOSTTY_RESOURCES_DIR in-process before engine init, and source the shell integration yourself in any shell you spawn.
  • Treat exit codes from the engine as hints, not facts.
  • Verify your release artifact the way your users extract it, not the way you do.

About Calyx

Calyx is a native macOS terminal (Swift 6.2, AppKit and SwiftUI, MIT) for running and supervising coding agents in parallel: an approval inbox for Claude Code and Codex permission prompts and for Grok and pi tool calls, an agent status sidebar driven by each CLI's own hooks, in-terminal diff review with line comments sent back to the agent, MCP tools that let agents read command output and language-server results, and the persistent sessions described above. It requires macOS 26 on Apple Silicon.

brew install --cask calyx
Enter fullscreen mode Exit fullscreen mode

Source: https://github.com/yuuichieguchi/Calyx
Docs: https://help.getcalyx.app

Thanks to Mitchell Hashimoto and the Ghostty contributors for making the engine embeddable in the first place. Everything above is a list of details; the fact that a solo developer can ship a GPU-rendered, VT-correct terminal as one component of a larger app is the headline.

Top comments (2)

Collapse
 
raknaos profile image
Raknaos

ghostty_runtime_config_s being the whole contract is the framing I wish I'd had. The detail that landed hardest is the synchronous INITIAL_SIZE / CELL_SIZE from inside ghostty_surface_new, before any observer of the first surface exists — that's the class of bug where the API isn't wrong, it's just earlier than the host's mental model of a callback.

The re-signing order (XPC services, nested apps, standalone executables, framework, outer app) reads like something only reproducible by breaking a release, and the getpwuid failure after the daemon loses its process context is brutal. How do you keep the action router honest as libghostty adds actions — is the 11-not-implemented list asserted against the header in CI, or maintained by hand when you bump the build?

Collapse
 
yuu1ch13 profile image
Yuuichi Eguchi

By hand, as part of the update procedure. The C enum comes into Swift as a non-exhaustive type, so the switch has a default case and the compiler can't check coverage; the libghostty update checklist has a step that greps GHOSTTY_ACTION_ out of the header and diffs it against the router, and that's where the count in the post comes from. Two things make that safe in practice: default logs the raw tag and returns false, so a tag added upstream can never be silently consumed, and the three intentional no-ops are pinned by a unit test that documents them as deliberate.