Skip to main content
Deploy Meerkat with prebuilt binaries or a build tailored to your application. The Homebrew tap, GitHub Release archives and SDKs’ automatic rkat-rpc download provide ready-to-run installs. This guide covers source builds, Rust embedding, container packaging and release-binary platform requirements. The build measurements help size a build host and choose compilation settings.

What A Build Compiles

The workspace has ~50 member crates. Its lockfile resolves ~670 package entries (~600 distinct crates) for the whole workspace with every feature, dev-dependency, and optional backend enabled. A default-feature cargo install rkat compiles ~380 crates. Most of those are ordinary. The cost is concentrated in a few very large first-party crates, each of which is one rustc and LLVM invocation: These four, plus the final rkat crate that pulls the whole surface into one binary, dominate both wall time and peak memory. The optional consumer profiles below therefore focus on job limits and per-package opt-level pins rather than only the dependency count; they are not the current Windows release recipe. Toolchain and native requirements:
  • The repository pins Rust 1.97.0 in rust-toolchain.toml; make install-build-deps installs exactly that toolchain into a checkout. The published crates declare rust-version = "1.97.0", so cargo install rkat needs Rust 1.97 or newer.
  • The default feature set needs a C compiler and libc headers (bundled SQLite and a few other C sources compile through the cc crate) and nothing else. TLS is rustls; no OpenSSL development package is required and the release gate rejects binaries that link one.
  • cmake is needed only by the optional live-webrtc feature of the meerkat facade and meerkat-rpc crates (meerkat-live webrtc, bundled Opus through audiopus_sys). rkat has no such feature, and no shipped binary enables it by default, so a cargo install of any of them never compiles it.

Build Cost

Two cold builds, measured for this page:
Both ran from an empty target directory with default features and Rust 1.94.1 on an 18-core Apple M5 Max with 128 GiB of RAM, so nothing was memory-constrained and Cargo ran 18 jobs in parallel. The first used Cargo’s default release profile; the second used the low-memory profile below (opt-level = 1 globally, opt-level = 0 for meerkat-machine-schema), with the same job count so the per-crate peaks are comparable. Peaks were sampled once per second from ps. How to translate that to your host:
  • The largest single crate is the floor, and only opt-level = 0 moves it. Even with CARGO_BUILD_JOBS=1, the build needs as much memory as its biggest rustc process. In both runs above that was meerkat-mob at ~11 GiB: going from opt-level = 3 to 1 halved the aggregate peak, the rkat crate, and meerkat-machine-schema, but left meerkat-mob where it was. A third run (the same rkat build with the low-memory profile plus meerkat-mob pinned to opt-level = 0) brought meerkat-mob down to ~6.3 GiB, after which the largest process is meerkat-runtime at ~7.4 GiB. Below that, the levers are the feature set (use --no-default-features and omit mob, rpc-surface, and the CLI openai-live feature to avoid their mob dependencies in a reduced production build), swap, and prebuilt binaries. See Features for scope and limits.
  • Aggregate memory scales with the job count. Cargo runs up to one rustc per core. The aggregate peaks above happened while four or five large first-party crates were compiling at once; with N jobs, budget for the N largest crates that can be in flight together. meerkat-mob, meerkat-runtime, and meerkat-machine-schema did overlap in the default build, and that overlap produced its peak.
  • Wall time scales with cores only up to the serial tail. The hundreds of small dependencies compile in parallel in the first few minutes, but the large first-party crates depend on each other, and the final rkat crate compiled alone for roughly the last third of the wall time. A 4-core cloud VM should expect a few times the wall time above, and the tail does not shrink with more cores. cargo install also has no warm cache to reuse, so every install is a cold build.
  • Disk is transient for cargo install. The temporary target directory is deleted after the binary is installed, but it must fit while the build runs. A checkout that keeps its target/ needs the full figure per profile.
The current Windows release recipe is separate from these local measurements. In .github/workflows/release.yml, build_binaries_windows_cross runs on ubuntu-24.04 and invokes .github/actions/build-windows-cross/action.yml. That action uses cargo-xwin with clang-cl, lld-link, and the Windows SDK to cross-compile the four surface binaries and package their ZIP archives. It adds an 8 GiB swap file when total swap is below 8 GiB; it does not set CARGO_BUILD_JOBS=2 or inject the per-package optimization pins shown below. The subsequent verify_windows_binaries job runs on windows-2025-vs2026, extracts the archives, executes each binary’s --version, checks rkat --help, and checks the SQLite lock profile rkat storage doctor --json reports (see SQLite Lock Profile); it does not compile them natively. These workflow settings are not new wall-time or memory measurements. Continuous integration (.github/workflows/ci.yml) uses BuildBuddy for its broad lane and hosted Linux for the required dense Mob topology stress; release binaries are produced by the separate release workflow. Rough sizing, derived from the peaks above:

Lowering Memory

Where profile settings come from

Cargo reads [profile.*] tables only from the root manifest of the build it is running. Two consequences matter here:
  • The Meerkat workspace ships no [profile.release], so its own release builds run at Cargo’s defaults (opt-level = 3, codegen-units = 16, lto = false, debug = false) except where a lane injects pins.
  • Even if it did, that would not reach you. cargo install rkat unpacks the published rkat crate as the root of its own temporary workspace; the repository’s root Cargo.toml is not part of that crate. A downstream binary that depends on meerkat = "=0.8.32" is likewise governed by its own root manifest, never by Meerkat’s.
So the consumer sets the profile. cargo install honors Cargo configuration from a .cargo/config.toml in the directory you run it from (or ~/.cargo/config.toml), from CARGO_PROFILE_RELEASE_* environment variables, and from --config on the command line. This was verified for this page with cargo install -v: with CARGO_PROFILE_RELEASE_OPT_LEVEL=1 set, rustc receives -C opt-level=1. Library embedders put the same tables in their own root Cargo.toml.

Low-memory profile

For hosts under 16 GiB, add the small-host pin. It is the only setting that moved meerkat-mob (~11.5 GiB at opt-level = 1, ~6.3 GiB at 0); it trades CPU on the orchestration path for memory, so measure your workload before shipping a binary built this way:
As an optional per-package alternative, keep everything else at full optimization by dropping the global opt-level line and keeping these pins. This is a consumer profile choice, not the current Windows release recipe:
The same settings on one command line, without a config file:
Per-package profile keys have no environment-variable form, which is why the package pin goes through --config; repeat the flag with profile.release.package.meerkat-mob.opt-level=0 for the small-host pin.

What each lever does

  • CARGO_BUILD_JOBS (or -j) caps the number of concurrent rustc processes. Aggregate memory is the sum of the compilers running at once, so this is the first lever on any memory-limited host. Wall time rises roughly in proportion.
  • opt-level: going from 3 to 1 removes most of LLVM’s expensive passes. Measured on the crates whose peak is in LLVM, it halved the rkat crate (~10.1 to ~4.8 GiB) and, together with the opt-level = 0 pin, meerkat-machine-schema (~9.4 to ~4.8 GiB), and it cut the whole build’s wall time from ~25 min 29 s to ~19 min 6 s. It did not move meerkat-mob; only opt-level = 0 did (~11.5 to ~6.3 GiB). The runtime cost of 1 is modest for an agent host that spends its time waiting on network I/O. opt-level = 0 is free for meerkat-machine-schema because it is a generated catalog, not hot code; on meerkat-mob it is a real trade.
  • codegen-units: LLVM optimizes a crate’s codegen units in parallel, bounded by your core count, so a crate split into 16 units can hold 16 LLVM contexts at once. Lowering it reduces that per-crate parallelism at the cost of a longer compile for that crate, and codegen-units = 1 also widens inlining scope, which can raise memory for the largest crates. Measure on your host before changing it; the release lane leaves it at 16.
  • lto: leave it at the default false. "thin" or "fat" LTO pulls the whole dependency graph into one optimization pass at link time and multiplies link-stage memory.
  • Features: crates/meerkat-cli/Cargo.toml lists the subsystems behind rkat’s default features (mob, rpc-surface, openai-realtime, comms, schedule, workgraph, mcp, skills, storage backends, providers). A narrower --no-default-features --features ... production build can remove the mob graph only if it omits mob and all transitive enablers, including rpc-surface (which selects meerkat-rpc/mob) and the CLI openai-live feature (which selects mob). Omitting just the direct mob feature while keeping rpc-surface still builds meerkat-mob and meerkat-mob-mcp. The repository’s surface feature matrix (scripts/run-surface-feature-matrix, behind make test-feature-matrix-surface) keeps three reduced rkat combinations compiling: --no-default-features --features session-store, session-store,mcp, and session-store,comms,mcp,workgraph. Other subsets are not exercised, so check that yours builds before relying on it. Reduced builds are source builds only; no reduced binaries are published (see the note under Homebrew Tap). This reduction is for the selected production package: workspace/test feature unification and dev-dependencies can bring mob back, and removing it is not a guarantee that every host below 8 GiB can compile the remaining graph.

Worker Stack Budget

Every host binary (rkat, rkat-rpc, rkat-rest, rkat-mcp, rkat-mob-mcp) runs its Tokio workers, blocking threads, and its own main future on one documented stack budget: 8 MiB per thread, the constant meerkat_runtime::host_stack::HOST_WORKER_STACK_BUDGET. The main future runs on a budgeted thread as well, so the platform main thread (1 MiB on Windows) never bounds a host. The number is measured, not guessed. On the RPC router harness (session create, turn start, session archive through the real method router) a release build fits 1 MiB and overflows at 512 KiB; a debug build, which the e2e lanes run, fits 4 MiB and overflows at 3 MiB because opt-level 0 gives every branch of a function its own stack slots. 8 MiB is twice the debug figure and eight times the release one. It is virtual address space per thread; pages are committed only when touched. Two gates hold the figures: a debug canary in the meerkat-rpc unit lane at 4 MiB and the nightly make stack-budget-release at 1 MiB in release. RKAT_WORKER_STACK_BYTES=<bytes> overrides the budget for stack-overflow diagnosis. Sweeping it distinguishes a bounded large frame (fails below one value, passes above) from unbounded recursion (fails at every value). Values below 65536 or that are not a byte count are refused with exit code 2 rather than guessed at. rkat-rpc still honours its older RKAT_RPC_WORKER_STACK_BYTES when the shared variable is unset. Meerkat also keeps two small Tokio runtimes of its own for the whole process, in a host binary and in a Rust embedding alike: the machine cleanup runtime (threads named meerkat-machine-cleanup) and the runtime-loop serving runtime (meerkat-runtime-serving). They run Core’s own session and mob work: machine cleanup, the custody of a session’s creation and recovery, and the runtime loops that apply each session’s turns. The turns themselves, with the host’s tools and whatever those spawn, run on the host’s runtime: a mob member’s turns on the runtime of the task that provisions it, the turns of a session an RPC session runtime serves on the runtime that session runtime is built on, and those of a session the host creates directly on the runtime that creates it. Size the workers of that runtime for the tools you compose. A host runtime that stops in the middle of a turn ends the turn as a crash would: the interrupted input and the queued ones stay non-terminal, nothing more is written for them, and the session’s next start resumes them. An input that reaches an idle session after the stop waits for that start too. Once the session’s runtime takes such an input up, as after a stop in the middle of a turn, the session’s registration waits for its reload, and a later input is refused until the next start, with nothing kept for it; an RPC session runtime refuses it with host_runtime_stopped (two inputs to the same session within milliseconds of each other right after the stop can see the second refused untyped instead, -32603, with nothing kept). On an RPC session runtime two more inputs are refused instead of waiting: a governed input, and an input to a help session, which is never rebuilt from storage and is refused with help_session_not_resumable. A turn whose task panicked, or whose session the service retired, fails with its error, as does a turn its caller drives directly. The registration such a stop leaves waiting for its reload stays in its machine until that machine reloads it, at the session’s next registration there, or the process exits. With realm persistence, whose hosting claims are file locks, it keeps the session’s lock until then, so any other machine, in this process or in another, is refused the session (ServedElsewhere); the stopped host’s session service and machine also stay in the process until it exits. When RKAT_WORKER_STACK_BYTES is unset the two process runtimes’ threads take 16 MiB, twice the budget, or RUST_MIN_STACK when that asks for more. They follow the same variable as the host: RKAT_WORKER_STACK_BYTES sets their thread stack exactly, below 16 MiB included, so the diagnosis sweep reaches them too. Their threads have an explicit size, so Rust itself would never apply RUST_MIN_STACK to them; this way an embedding whose workers or debug build need more than 16 MiB raises these threads with the same RUST_MIN_STACK it gives its own. An invalid RKAT_WORKER_STACK_BYTES fails every operation that needs one of these runtimes until it is corrected, with the refusal a host binary gives at startup. RKAT_RPC_WORKER_STACK_BYTES does not reach them.

Worker Threads

A host binary’s Tokio runtime starts one worker thread per available CPU, as std::thread::available_parallelism counts them: a cpuset or a cgroup CPU quota (docker run --cpus, a Kubernetes CPU limit) lowers the count, a pids limit does not. RKAT_WORKER_THREADS=<n> sets the count for every host binary; when it is unset, Tokio’s own TOKIO_WORKER_THREADS is read in its place. A value that is not a whole number of at least 1 is refused with exit code 2 rather than guessed at. Two small internal runtimes (the runtime-loop serving runtime and the machine cleanup runtime) always run two workers each. A Rust host that builds its own Tokio runtime can apply the same setting: meerkat_runtime::host_stack::worker_threads_from_env() returns the validated count, or None for the default.

SQLite Lock Profile

Meerkat’s stores run on the SQLite that libsqlite3-sys bundles. Three defaults of that build put one process-wide mutex in front of every connection in the process, so sessions that share a host process queue on each other: On a shared 192-core host (meerkat-sqlite’s process_wide_locks_bench test measures this), sixteen threads that each opened their own database opened fewer connections per second together with the defaults (about 700) than one thread alone (about 3,000), and 10,000 to 24,000 with allocator statistics off. Sixteen threads writing to their own databases ran about six times faster with one page cache per connection. The overflow statistics lock is taken least often: turning it off as well made no difference beyond the host’s run-to-run variation. One build setting turns all three off (meerkat_sqlite::REQUIRED_LIBSQLITE3_FLAGS):
Nothing Meerkat calls reads what these provide (SQLite’s memory and page-cache statistics, its heap limits, sqlite3_release_memory). The repository sets the value for every build it makes, Cargo through .cargo/config.toml and Bazel through MODULE.bazel, so the release binaries carry it. The release workflow runs rkat storage doctor --json on each rkat it builds where the runner can execute it, and fails when a lock is on: every target of the GitHub-hosted lane, the x86_64 Linux target of the BuildBuddy lane (its other targets build from the same Bazel annotation), Intel macOS under Rosetta, and Windows on the Windows verification runner. A build outside the repository does not read those files: set the variable yourself for cargo install rkat and for an application that embeds Meerkat. Without it, every host binary still turns allocator statistics off as the first statement of main, but the shared page cache and the overflow statistics stay on. rkat storage doctor reports the profile its binary runs with (the sqlite_lock_profile object of --json, and a line of the text report) and warns with the finding sqlite-lock-profile when a lock is on. A process that opens its first store connection with a lock on also logs a warning that names the variable. A Rust application that embeds Meerkat sets the variable for its own build, and calls meerkat_sqlite::disable_memory_statistics_at_process_start() as the first statement of its main. The function is unsafe: SQLite accepts the switch only before anything uses it, and no other thread may use SQLite while it runs. meerkat_sqlite::check_lock_profile() and meerkat_sqlite::SqliteLockProfile::effective() tell the application which locks it runs with.

Containerizing The Binaries

A Rust binary built for a *-unknown-linux-gnu target links the builder’s glibc dynamically and records the newest glibc symbol version it used. The runtime image must provide a glibc at least that new, or the loader refuses to start the binary with version 'GLIBC_2.xx' not found. The reliable way to get that right in a two-stage image is to pin one Debian release for both stages, so the builder’s glibc is the runtime’s glibc:
Notes on that file:
  • ARG DEBIAN_RELEASE is declared before the first FROM and used in both FROM lines, so the two stages cannot drift apart when someone bumps one of them. rust:1.97-bookworm matches the pinned toolchain family.
  • --locked builds with the Cargo.lock shipped inside the rkat crate, so the container compiles the dependency set the release was tested with.
  • To build from a checkout instead of crates.io (unreleased commits, local patches), replace the RUN cargo install line with WORKDIR /src, COPY . ., and cargo build --locked -p rkat --release, and copy /src/target/release/rkat (the build stage above sets no WORKDIR, so without that line the checkout lands in /). The repository’s own examples/035-mdm-tux-rs/Dockerfile does exactly that for the example’s binaries, with the same rust:1.97-bookworm build stage and debian:bookworm-slim runtime stage.
  • The companion surfaces are separate crates with the same shape: cargo install meerkat-rpc (binary rkat-rpc), meerkat-rest (rkat-rest), and meerkat-mcp-server (rkat-mcp).
  • rkat resolves project realm state under .rkat/ in its working directory and the global realm’s config under ~/.rkat/ (see Configuration); mount a volume at /data, and point HOME at persisted storage if the global realm matters, so state outlives the container.
If you do not need to compile at all, the runtime stage alone can unpack a prebuilt Linux release archive. debian:bookworm-slim ships glibc 2.36, above the 2.31 floor the release binaries are built against (next section):
Verify the download against the release’s checksums.sha256 (and, if you use the GitHub CLI, its build provenance attestation; see below) before trusting it in production. Not covered by the prebuilt binaries: Alpine and other musl-based images. No musl target is published, and the glibc binaries are not tested under gcompat. Build from source on such a base or use a Debian-family image.

Thread and process limits

A container’s pids limit (docker run --pids-limit, the cgroup’s pids.max, the kubelet’s pod PID limit) counts threads as well as processes, and so does RLIMIT_NPROC (ulimit -u) across all of a user’s processes. A host started on a machine with more CPUs than its limit allows cannot start one worker per CPU (see Worker Threads), and fails at startup:
Releases before 0.9.3 print only the text up to (os error 11). Set the worker count well below the limit, for example docker run --pids-limit 256 -e RKAT_WORKER_THREADS=8 ...; on releases before 0.9.3, TOKIO_WORKER_THREADS=8 has the same effect. Size the limit for everything the host runs, not only its workers:
  • An idle rkat-rpc runs about ten threads beyond its workers: its main threads, the stdin reader, a file watcher, scheduler and delivery threads, and a few blocking-pool threads.
  • Each live session with a persistent store adds one thread.
  • Blocking work (file and SQLite I/O, DNS lookups) runs on Tokio’s blocking pool, which can grow to 512 threads per runtime under load; idle ones exit after 10 seconds. A blocking-pool spawn the limit refuses is queued until one of the pool’s threads is free, but only while the pool has a thread: in a pool with none, Tokio panics instead (OS can't spawn worker thread).
  • Every MCP stdio server and shell command is a process inside the same limit, with threads of its own.
Near the limit, other thread spawns are refused too, later in startup or while serving, and what happens depends on which one. Usually the operation that needed the thread fails, and a refusal that ends the process as an I/O error gets a hint: line naming the usual causes and RKAT_WORKER_THREADS; a library that does not handle the refusal can abort the process instead. Leave headroom rather than sizing the limit to the worker count. Since 0.9.4 one such library failure no longer aborts the host: when a SQLite change watch registers its file notifications and notify’s inotify event-loop thread cannot start, the watch keeps running on its bounded sweep alone (every 5 seconds by default) and logs “sqlite change watch notifications are unavailable; the watch runs on its sweep alone”. The same happens at an inotify instance or watch limit; raise fs.inotify.max_user_instances or fs.inotify.max_user_watches on the host and restart to get notifications back. Each watch registers on a thread of its own that ends once the platform has registered (at once with inotify), and at most 64 registrations run at once in a process.

Where The Prebuilt Binaries Come From

.github/workflows/release.yml builds and publishes the release assets when a v* tag is pushed. Each of the four binaries (rkat, rkat-rpc, rkat-rest, rkat-mcp) is packaged per target as <binary>-<version>-<target>.tar.gz (.zip on Windows), alongside checksums.sha256 and index.json. The Homebrew tap and the SDK bootstrap consume these same assets. Targets:
  • x86_64-unknown-linux-gnu
  • aarch64-unknown-linux-gnu
  • aarch64-apple-darwin
  • x86_64-apple-darwin
  • x86_64-pc-windows-msvc
Every archive carries a GitHub build provenance attestation (actions/attest-build-provenance), which binds the file’s digest to the workflow run and commit that produced it:

Linux glibc floor

The Linux GNU binaries are built inside the buildpack-deps:bullseye image (Debian 11, glibc 2.31) on both release lanes: the BuildBuddy lane pins that image as the execution container in tools/bazel/platforms/BUILD.bazel, and the GitHub-hosted lane runs its Linux jobs under the same container:, because building directly on the Ubuntu 24.04 runner (glibc 2.39) would silently raise the floor. Packaging then runs scripts/check-linux-release-binary-portability.sh on every Linux binary and fails the release if either check trips:
  1. Any versioned glibc symbol reference is newer than the declared floor (MEERKAT_GLIBC_FLOOR=2.31).
  2. The binary has a dynamic NEEDED entry on libssl or libcrypto. All first-party TLS is rustls, so an OpenSSL dependency means a crate regressed into a native-TLS stack.
The gate exists because v0.8.21 shipped binaries referencing GLIBC_2.34 that Debian 11 could not load; the floor is enforced on the produced file, not assumed from the build image. What that guarantees: a published Linux binary starts on any x86_64 or aarch64 distribution whose glibc is 2.31 or newer (Debian 11 and later, Ubuntu 20.04 and later, Debian-based container images from bullseye onward) and needs no OpenSSL runtime package. What it does not guarantee: musl-based systems (see above), or distributions older than glibc 2.31.

Other platforms

The macOS binaries carry no notarization and the release workflow asserts no minimum macOS version (there is no deployment-target pin or gate); the GitHub-hosted lane ad-hoc codesigns them. The Windows binary is x86_64 only. The packaging step checks that every binary embeds the release version, so rkat --version on an installed binary tells you exactly which release it is.

Meta

Meta’s gateway binaries are built and released by the meerkat-meta repository’s own lane; the glibc floor and portability gate described on this page do not apply to them. Start from the Meta quickstart for its install and build paths.

See Also

  • CD and distribution for the release pipeline, Homebrew tap, SDK bootstrap, and credentials.
  • Build And CI for the contributor build, test, and BuildBuddy lanes.
  • Quickstart for the featured install paths.
  • Upgrading for per-release upgrade steps, including shutdown deadlines and termination grace periods.