Simple, single-binary AI agent inspired by OpenClaw. Talk to your claw over Signal, and have it carry out advanced computing and coding tasks without a public webhook.
mkclaw has host-managed tools:
Execute: run scripts inside the guest VM, optionally with approved host-held secrets injected as env vars.RestartVM: request a governed guest VM restart when health checks fail.Subagent: spawn a fresh-context subagent thread and get a UUID plus transcript path.WaitForSubagentFinish: wait for specific subagent IDs and return structured results/pending IDs.ContinueSubagent: append a follow-up message to an existing subagent conversation.RequestNewSecret: ask the Signal owner to add or update a host-held secret value.
The guest VM is an OCI-based micro-VM powered by krunvm.
All other features are built around these tools:
- Crons: Markdown files that periodically schedule agent tasks.
- Hooks:
.mdfiles or hashbang scripts run before each user or cron run, with their output creating the LLM's system prompt.
All of your crons/hooks live in the VM's /root directory and are shared with
your system. For a starting point, I recommend most users fork their root from
mkclaw-vanilla: the "default distribution" of mkclaw, which uses crons/hooks
to add support for Agent Skills, Web Browsing,
Web Search, and Memory.
Warning
Security Notice
mkclaw is an LLM that runs arbitrary code inside a guest VM.
We take multiple steps to isolate the environment: a micro-VM, and a guest/host model where AI provider keys are not directly exposed to the guest.
However, IT IS YOUR RESPONSIBILITY to limit mkclaw access to sensitive
information, such as API keys, private data, credit cards, logins, etc.
See: The Lethal Trifecta
root/
hooks/
0001_example.md
0002_example.py
crons/
0001_example.md
threads/
<thread-uuid>.jsonl
threads-archive/
<legacy-thread-id>-<timestamp>.jsonl
See mkclaw.example.yaml for a starting point.
rootpoints to the host directory mounted as/rootin the guest.ai.providermust beopenai-responses.ai.base_urlpoints at an OpenAI-compatible API root (defaulthttps://api.openai.com/v1).ai.default_profileandai.profilesselect model/reasoning combinations; subagents may choose a configured profile.ai.compactionenables token-budget transcript compaction through/root/compaction.md.channel.signalconfigures Signal polling throughsignal-cli.secretsis the host-held env-var vault available toExecuteafter approval.secret_approvalsstores durable cron-scoped approvals for secret use.vm.imageis the OCI image booted by krunvm (defaultghcr.io/openai/codex-universal:latest).vm.nameis the krunvm VM name (defaultmkclaw).vm.cpusis the number of vCPUs (default: host parallelism).vm.memis guest memory in MiB (default: 4096).vm.vm_dirstores VM artifacts.
mkclaw uses signal-cli for Signal
send/receive. This avoids public webhooks: the host periodically runs
signal-cli receive --json --timeout ... and sends replies with
signal-cli send.
Signal does not provide an official bot API. This setup is best treated as a
personal/low-volume channel. You must install and configure signal-cli before
starting mkclaw.
Example setup:
# Register a dedicated Signal number, or link signal-cli as a secondary device.
# Exact commands vary by signal-cli version; see signal-cli's documentation.
signal-cli -a +15551230000 register
signal-cli -a +15551230000 verify CODE
# Quick smoke test.
signal-cli -a +15551230000 send -m "hello" +15551234567
signal-cli -a +15551230000 receive --json --timeout 5
Config:
channel:
signal:
account: "+15551230000"
allowed_numbers:
- "+15551234567"
signal_cli_path: signal-cli
poll_interval_seconds: 5
receive_timeout_seconds: 5Only numbers in allowed_numbers can enqueue agent work.
Secrets are host-held env var values. They live in top-level secrets: in
mkclaw.yaml and are never shown to the model:
secrets:
GITHUB_TOKEN: "ghp_..."
secret_approvals: []The agent can see only the secret names. When a command needs one, it calls
Execute with a secrets list:
{
"text": "#!/usr/bin/env bash\ngh pr create --title '...' --body '...'",
"timeout": 300,
"secrets": ["GITHUB_TOKEN"]
}mkclaw sends a Signal approval prompt to the first number in
channel.signal.allowed_numbers before injecting those env vars into that one
process:
Secret use requested
Secrets: [GITHUB_TOKEN]
Command hash: <sha256>
Reply:
1 approve once
2 approve this exact command + secret set for 10 minutes
3 approve this exact command + secret set for 1 hour
no reject
Cron-originated requests also include:
4 approve this secret set + command hash for this cron revision
Option 4 persists a durable approval under secret_approvals; it is tied to
the secret names, the exact Execute command hash, the cron file path, and the
cron content revision. Editing the cron changes the revision and requires a new
approval.
If the agent needs a missing secret, it calls RequestNewSecret with env var
names and a reason. You approve by replying with values:
approve <approval-id>
GITHUB_TOKEN=ghp_...
To reject:
no <approval-id>
Approved secret values are patched into the secrets: block in mkclaw.yaml
with file mode 0600 on Unix. mkclaw intentionally does not use yaml-edit
for this path because that library can corrupt inline empty YAML blocks during
mutation.
Security tradeoff: scripts run inside the VM, so a compromised guest could still exfiltrate a secret during an approved process lifetime. The vault reduces exposure from "credentials always present in the VM" to "credentials present only for one approved process".
Thread state is stored in the guest under /root/threads/*.jsonl as native
append-only mkclaw JSONL events. Runtime state is derived by reading the log;
there is no file-size compare-and-swap token and no pending-inbound sidecar.
If guestd is about to write a native thread over an older non-native .jsonl
thread file, it first moves the legacy file to /root/threads-archive/.
The guest daemon (mkclaw-guestd.py) is embedded in the mkclaw binary and
sent to a tiny python3 -c bootloader over the krunvm child stdin stream. It is
not mounted as a persistent guest file, so updating mkclaw updates guestd on the
next guestd restart without recreating the VM.
The guest image must include python3. Guestd uses a netstring-framed JSON RPC
protocol over krunvm stdin/stdout; guest networking is not part of the mkclaw
control plane.
On first run, mkclaw will:
- Ensure a local containers policy exists at
vm.vm_dir/containers-home/.config/containers/policy.json - Reuse an existing krunvm VM with the configured name, or
krunvm createa new VM from the OCI image with therootdirectory mounted as/root krunvm startthe VM with piped stdio, executing a small Python bootloader as the entry command- Send the embedded guestd source over stdin and wait for a stdio
healthresponse
Workload networking is handled transparently by krunvm's TSI (Transparent Socket Impersonation) — no TAP device or host-side routing needed. The mkclaw control plane uses krunvm stdio instead of guest networking. The OCI rootfs is read-write via buildah, so the guest can install packages and modify system files freely.
Required host tools: krunvm, buildah, and signal-cli.
Optional: build with --features systemd to inhibit system sleep while
mkclaw is running. This uses the systemd-inhibit CLI and requires it to be
available in PATH.
mkclaw uses buildah unshare krunvm ... with HOME pointed at
vm.vm_dir/containers-home, so it can use its own
.config/containers/policy.json without requiring a global
/etc/containers/policy.json.
# 1) Install krunvm, buildah, and signal-cli
# See https://github.com/containers/krunvm and https://github.com/AsamK/signal-cli
# 2) Configure mkclaw.yaml
cp mkclaw.example.yaml mkclaw.yaml
# 3) Start mkclaw
mkclaw
nix develop
mkclaw uses the OpenAI-compatible Responses protocol directly:
openai-responses: usesPOST /v1/responseswith Responses-style function tools.
Requests send store: false.
Hooks are executed in lexicographic order before each user or cron run. The stdout from each hook is appended to the system prompt with a blank line between entries.
Supported hook types:
- Markdown (
.md): included directly in the system prompt. - Hashbang scripts (first line starts with
#!): executed and their stdout is included.
Cron files are markdown with frontmatter:
---
name: "Example"
cron: "0 */6 * * * * *"
---
Do something every 6 hours.
Cron schedules are interpreted in UTC.