Introduction

Praxis is an open-source research and experimentation platform for discovering, controlling, and orchestrating computer-use AI agents across endpoints.

As AI coding agents become more prevalent - tools that can read files, execute commands, and interact directly with systems - understanding their security properties becomes critical. Praxis helps enrich our understanding of what's possible when you have legitimate access to systems where these agents run, and what that means for endpoint security.

Built by Origin for security research and red team operations.

Why Does This Exist?

AI coding assistants are everywhere now - Claude Code, Codex CLI, Gemini CLI, Microsoft 365 Copilot. These tools can read your files, execute commands, browse the web, and interact with APIs. From a security perspective, they're incredibly interesting.

Praxis started as a question: what can you do if you have access to a system running one of these agents? Not by exploiting vulnerabilities in the agents themselves, but by using the access you already have to see what they're doing and repurpose their capabilities.

This matters for:

  • Red teams exploring post-compromise scenarios where AI agents are present
  • Security researchers understanding the attack surface these tools create
  • Blue teams wanting to know what visibility they have (or don't have) into agent activity

What Can Praxis Do?

FeatureDescription
Agent DiscoveryFingerprint and detect computer-use agents on endpoints
ReconnaissanceEnumerate tools (MCP servers, skills), configurations, and session histories
Config VisibilityView and edit agent configuration files directly
Traffic InterceptionMITM proxy for agent-to-LLM traffic
Agent DialogCreate interactive sessions with agents
OrchestratorFree-form multi-tool AI operator across the Praxis network
Help AssistantIn-TUI documentation chat (Ctrl+H) grounded in the shipped docs
Semantic OperationsDefine and chain natural language tasks for multi-step automation
Chain AutomationTrigger chains automatically on schedules, intercept matches, or new node events
Terminal AccessPTY terminal on remote nodes

The Components

Praxis has three main pieces:

┌───────────────────────────────────────────────────────────┐
│                                                           │
│                       praxis (TUI)                        │
│                                                           │
└─────────────────────────────┬─────────────────────────────┘
                              │
                              │ RabbitMQ
                              │
┌─────────────────────────────▼─────────────────────────────┐
│                                                           │
│                         Service                           │
│         (Backend + Database + Operation Manager)          │
│                                                           │
└─────────────────────────────┬─────────────────────────────┘
                              │
                              │ RabbitMQ
                              │
        ┌─────────────────────┴─────────────────────┐
        │                                           │
        │                                           │
┌───────▼───────┐                         ┌─────────▼─────────┐
│               │                         │                   │
│     Node      │                         │       Node        │
│  (Target #1)  │                         │    (Target #2)    │
│               │                         │                   │
└───────────────┘                         └───────────────────┘

Node runs on target systems. It discovers agents, intercepts traffic, handles sessions, and reports back to the service. Nodes are stateless - all the interesting data lives on the service.

Service is the central backend. It stores operation definitions, chain workflows, intercepted traffic, and recon results. It also runs the semantic operations manager that orchestrates agent tasks.

praxis (TUI) is the first-party client. It's a terminal user interface that connects to the service to drive everything — selecting nodes, viewing agents, running operations, building chains.

Early Release Notice

This is an early release to showcase initial capabilities. It is not yet ready for full-scale red teaming or production use - although you can certainly experiment to your heart's content.

The platform is under active development:

  • Some features are incomplete or experimental
  • The codebase is evolving rapidly
  • This is not designed to be stealthy - it installs root certificates, modifies system settings, and is generally quite noisy

We're releasing early to get feedback and contributions from the community.

Getting Started

Ready to try it out? Head to the Installation guide.

Installation

The Praxis service runs only on Linux — natively (systemd) or inside a Docker container. The CLI (TUI) runs natively on every supported platform. The one-liner installers walk you through how you want the service deployed; the CLI is always built natively.

The Praxis service is Linux-only. Windows and macOS can only run it in Docker — there is no native service path on either. Linux can run it natively (systemd) or in Docker; Docker is offered there as an alternative when you'd rather not install RabbitMQ + systemd units on the host.

Quick Install (One-Liner)

Linux / macOS

curl -fsSL https://praxis.originhq.com/install.sh | bash

The installer asks how to install the service:

  • Native install (Linux only) — installs the binaries to /usr/local/bin, the praxis-service.service systemd unit to /etc/systemd/system, config to /etc/praxis/env, and data to /var/lib/praxis. Requires a running RabbitMQ broker; the installer creates the praxis RabbitMQ user automatically.
  • Docker install (Linux + macOS) — clones the repo into ~/.praxis-docker and runs docker compose up --build -d. The Praxis container runs systemd as PID 1, so praxisctl works the same inside the container as on a native install. Pick this on macOS because there's no native option, or on Linux if you don't want to install RabbitMQ + systemd units on the host.
  • Client only — only installs the praxis CLI (TUI); no service is deployed.

The CLI is always installed natively regardless of the choice.

For non-interactive use:

curl -fsSL https://praxis.originhq.com/install.sh | bash -s -- --service native
curl -fsSL https://praxis.originhq.com/install.sh | bash -s -- --service docker
curl -fsSL https://praxis.originhq.com/install.sh | bash -s -- --cli

Prebuilt binaries vs. building from source

By default, the native install (--service native, --cli, or the corresponding interactive choices) downloads prebuilt x86_64 binaries from the latest GitHub Release. This is fast and requires no Rust toolchain. In the interactive flow, a follow-up prompt asks which method to use.

Pass --src to build the binaries from source instead (requires cargo + git; the installer will install Rust via rustup if missing):

curl -fsSL https://praxis.originhq.com/install.sh | bash -s -- --service native --src
curl -fsSL https://praxis.originhq.com/install.sh | bash -s -- --cli --src

--src has no effect on --service docker, which always builds from source inside the container. Prebuilt binaries exist for Linux/x86_64, macOS/arm64 (Apple Silicon), and Windows/x86_64. The Linux/macOS installer falls back to --src automatically on unsupported architectures, including Intel Macs; the Windows installer downloads the Windows binary unless -Src is passed.

Cross-compiling the Windows node binary (optional)

Add --with-win-node to a native install to also stage the Windows praxis_node.exe next to the Linux node binary at /usr/local/share/praxis/nodes/praxis_node_windows.exe. Useful when the service needs to deploy nodes to Windows targets without pulling them from a release.

curl -fsSL https://praxis.originhq.com/install.sh | bash -s -- --service native --with-win-node

By default this downloads praxis_node-windows-x86_64.exe from the GitHub release. Combined with --src it cross-compiles instead, which requires mingw-w64 and rustup (the rust target x86_64-pc-windows-gnu is installed automatically). Install mingw-w64 with your distribution's package manager:

  • Debian/Ubuntu: sudo apt-get install mingw-w64
  • Fedora/RHEL: sudo dnf install mingw64-gcc
  • Arch: sudo pacman -S mingw-w64-gcc
  • macOS: brew install mingw-w64

The flag has no effect with --cli, --service docker, or interactive mode — for those, use praxis-bin (AUR) or download the Windows node binary from the GitHub release if you need it.

Windows

The Praxis service is Linux-only, so on Windows the installer runs the service in Docker — that's the only option for the service on Windows. The CLI (TUI) is always installed natively. By default it's downloaded from the latest GitHub release; pass -Src to build from source (requires Rust + git).

irm https://praxis.originhq.com/install.ps1 | iex

The installer asks how you want to install the service:

  • Docker install — runs the Praxis container alongside RabbitMQ
  • Client only — only installs the praxis.exe CLI; no service

Non-interactive:

.\install.ps1 -Service docker
.\install.ps1 -Cli
.\install.ps1 -Cli -Src      # build praxis.exe from source instead of downloading
.\install.ps1 -Remove

If Docker is not installed, install Docker Desktop first. If you use -Src and Rust is missing, install it via rustup.

Native install — RabbitMQ prerequisite

Native installs require RabbitMQ to be installed and running before the installer runs. The installer checks for RabbitMQ and warns (without aborting) if it's missing — you'll need to install/start it and create the praxis user yourself before the service can connect.

# Debian/Ubuntu
sudo apt-get install rabbitmq-server
sudo systemctl enable --now rabbitmq-server

# Fedora/RHEL
sudo dnf install rabbitmq-server
sudo systemctl enable --now rabbitmq-server

# Arch
sudo pacman -S rabbitmq
sudo systemctl enable --now rabbitmq-server

The installer creates the praxis RabbitMQ user and grants it permissions automatically.

What native install lays down (Linux)

  • /usr/local/bin/praxis_service — backend service
  • /usr/local/bin/praxis_cli — CLI binary
  • /usr/local/bin/praxis — symlink to praxis_cli (preferred command name)
  • /usr/local/bin/praxisctl — service control utility
  • /usr/local/share/praxis/nodes/praxis_node_linux — node agent
  • /usr/local/share/praxis/nodes/praxis_node_tiny_c_linux — optional minimal C node, staged when available (also staged for the Windows target via --with-win-node)
  • /etc/systemd/system/praxis-service.service — system-wide systemd unit
  • /etc/praxis/env — service config (PRAXIS_RABBITMQ_URL, etc.)
  • /var/lib/praxis/ — data directory (SQLite database lives here by default)
  • A dedicated praxis system user runs the service

Manage and use Praxis through the praxis TUI — it's the only first-party supported client.

What docker install lays down

The repo is cloned into ~/.praxis-docker. docker compose brings up two services:

  • rabbitmqrabbitmq:3-management with the praxis user pre-created
  • praxis — Praxis container running systemd as PID 1; praxisctl works inside the container

The MCP server and Claude bridges are exposed on ports 8585, 8586, and 8587.

Removing

# Linux/macOS — removes native install + docker install
curl -fsSL https://praxis.originhq.com/install.sh | bash -s -- --remove

# also wipes /etc/praxis and /var/lib/praxis
PRAXIS_REMOVE_DATA=1 curl -fsSL https://praxis.originhq.com/install.sh | bash -s -- --remove
# Windows
iex "& { $(irm https://praxis.originhq.com/install.ps1) } -Remove"

Pinning a Specific Version

# Linux/macOS
PRAXIS_VERSION=v0.10.0 curl -fsSL https://praxis.originhq.com/install.sh | bash
# Windows
$env:PRAXIS_VERSION = "v0.10.0"; irm https://praxis.originhq.com/install.ps1 | iex

Controlling the service — praxisctl

After a native (or docker) install, praxisctl is the single entry point for service lifecycle and configuration. It wraps systemctl and edits /etc/praxis/env.

# Service (praxis-service.service)
praxisctl start
praxisctl stop
praxisctl restart
praxisctl enable      # auto-start at boot
praxisctl disable
praxisctl status

# Configuration
praxisctl set-rabbitmqurl amqp://praxis:praxis@localhost:5672
praxisctl get-rabbitmqurl
praxisctl config show
praxisctl config edit       # opens /etc/praxis/env in $EDITOR

praxisctl re-execs itself under sudo when run by an unprivileged user.

Beyond the named subcommands above, praxisctl also exposes a generic escape hatch: praxisctl set <key> <value> / get <key> read and write arbitrary keys in the env file, praxisctl config path prints the env file path, and praxisctl version prints the praxisctl/service version.

Inside the docker install, the same commands work via docker compose:

cd ~/.praxis-docker
docker compose exec praxis praxisctl status
docker compose exec praxis praxisctl set-rabbitmqurl amqp://praxis:praxis@rabbitmq:5672

Configuring the CLI — praxis set-rabbitmqurl

The praxis CLI reads its RabbitMQ URL (key PRAXIS_RABBITMQ_URL) from a config file whose location depends on the OS — ~/.config/praxis/config on Linux, ~/Library/Application Support/praxis/config on macOS, or %APPDATA%\praxis\config on Windows — and falls back to amqp://praxis:praxis@localhost:5672 if no config is set.

praxis set-rabbitmqurl amqp://praxis:praxis@my-server:5672
praxis config         # show effective URL and config file path
praxis                # launch the interactive TUI
praxis --status       # one-shot connection check
praxis -C "node list" # one-shot command

There is no --rabbitmq flag and no PRAXIS_RABBITMQ_URL environment variable on the CLI — point users at praxis set-rabbitmqurl instead.

Getting Node Binaries

A native install lays down praxis_node_linux at /usr/local/share/praxis/nodes/. To also stage the Windows node binary alongside it, use --with-win-node (see above).

The praxis-bin AUR package ships both praxis_node_linux and praxis_node_windows.exe automatically. The same two binaries are available as standalone assets on every GitHub Release.

Running Nodes

chmod +x praxis_node
./praxis_node

By default, nodes connect to RabbitMQ at localhost:5672. Override per-node via the env var:

PRAXIS_RABBITMQ_URL=amqp://praxis:praxis@your-server:5672 ./praxis_node

Version Compatibility

Nodes must match the service version. The RabbitMQ message format can change between versions, so a v0.2 node talking to a v0.1 service might not work correctly.

Next Steps

  1. Configure LLM providers
  2. Walk through the Quick Start

Configuration

Praxis uses LLMs for several features-semantic operations, tool discovery during recon, traffic summarization. You'll need to configure at least one provider to use these capabilities.

LLM Providers

Open Settings (Ctrl+S) → LLM in the praxis TUI.

Adding a Model

In Settings (Ctrl+S), stay on the LLM tab, use / to select Add Model, and press Enter. In the form:

  1. Choose a Provider
  2. Enter your API Key (optional for local providers — Ollama and Custom)
  3. For Custom, and optionally for Ollama, set a Base URL
  4. Refresh the provider model list when supported, or enter a model name manually
  5. Save with Ctrl+S

Use Tab / Shift+Tab to move between Settings tabs. The TUI status bar lists the available form controls.

Supported Providers

Anthropic, OpenAI, Google (Gemini), Groq, Cerebras, Mistral, xAI, NVIDIA, MiniMax, Moonshot, Fireworks AI, OpenRouter, Ollama (local), Custom (OpenAI-compatible).

Local Model Providers

Two providers are designed for local or self-hosted inference:

Ollama — defaults to http://localhost:11434/v1, so if you are running a stock Ollama install nothing else is needed. API key is optional. Model discovery uses Ollama's native /api/tags endpoint, so the model-refresh action works even though Ollama is strictly OpenAI-API compatible for inference. Override the base URL on the model definition if Ollama is listening elsewhere.

Custom (OpenAI-Compatible) — for vLLM, llama.cpp, LM Studio, Text-Generation-Inference, or any endpoint that implements /v1/chat/completions. You must set a base URL on the model definition; API key is optional. Model discovery probes /models on the configured base URL.

Feature Assignment

Once you've added models, assign them to features under Feature Selection:

Orchestrator - Powers an interactive AI agent that can autonomously manage operations. It will drive target agents through multi-turn conversations to achieve a given task, so it needs a capable model that follows tool-calling instructions reliably. A companion Max Tokens setting caps how long its responses can run.

Semantic Operations - Used when executing operations through agents. Pick something capable.

Semantic Parser - Used during semantic recon to extract tool definitions from config files. Speed matters here since it runs multiple times; a fast model like Haiku or GPT-4o-mini works well.

Traffic Parser - Summarizes intercepted traffic. Again, speed is valuable; you don't need the most powerful model.

Documentation Helper - Powers the Help Assistant (Ctrl+H), which answers questions about using Praxis from the bundled documentation. Falls back to the Orchestrator model when unset.

Speed vs. Capability

For parser features (Semantic Parser, Traffic Parser), we recommend providers with fast inference:

  • Cerebras and Groq have very fast time-to-first-token and overall throughput
  • This matters when you're running recon across multiple agents or parsing lots of traffic

For Orchestrator and Semantic Operations, capability matters more than raw speed. Use a model that's good at reasoning and tool use.

Environment Variables

Most configuration is done through the praxis TUI, but some things are set via environment variables:

Service

VariableDefaultDescription
PRAXIS_DATABASE_URLSQLite in home dirDatabase connection string
PRAXIS_RABBITMQ_URLamqp://praxis:praxis@localhost:5672RabbitMQ URL
RUST_LOGinfoLog level filter (tracing EnvFilter syntax)

Node

VariableDefaultDescription
PRAXIS_RABBITMQ_URLamqp://praxis:praxis@localhost:5672RabbitMQ URL
RUST_LOGinfoLog level filter (tracing EnvFilter syntax)

Database

By default, Praxis uses SQLite stored at ~/.praxis/operations.db. For PostgreSQL and production deployments, see Database Configuration.

Model Reference Format

When specifying models in operations or chains, use the format:

provider::model

For example:

  • anthropic::claude-sonnet-4-20250514
  • openai::gpt-4o
  • groq::llama-3.3-70b-versatile

This lets you override the default model for specific operations that might need more (or less) capability.

Next Steps

With LLMs configured, you're ready to:

Quick Start

Let's walk through the basic workflow: connecting a node, discovering an agent, running recon, and executing an operation.

Prerequisites

You should have:

  • Praxis service running (via Docker or native build)
  • At least one LLM configured (see Configuration)
  • A node running on a system with an AI agent installed
  • The praxis TUI installed (see Installation)

Step 1: Check Your Node

Launch the TUI:

praxis

Open the Nodes window with Ctrl+L. You should see your node in the node list. Use / to select it, then press Enter or to focus the agent pane. The detail pane shows:

  • Machine name and OS details
  • Detected agents — which AI assistants were found
  • Status of interception, sessions, etc.

If no agents show up, make sure the target system actually has Claude Code, Codex CLI, Gemini CLI, or another supported agent installed and configured.

Step 2: Select an Agent

In the agent pane, use / to select an agent. This focuses all subsequent recon and session actions on that agent.

Step 3: Run Reconnaissance

With an agent selected, press r to open the Recon overlay. This performs static reconnaissance:

  • Discovers MCP servers and other tool integrations
  • Lists configuration files and their contents
  • Shows session history — past conversations and their locations
  • Enumerates project paths where the agent has been used

Switch tabs with Tab (or 1 2 3) to browse Config, Tools, and Sessions. Press r to refresh static recon.

Semantic Recon

For deeper discovery, press Ctrl+U to run semantic recon (requires an LLM configured for "Semantic Parser"). This uses the LLM to parse configuration files and extract tool definitions that might not be obvious from static analysis. It also creates sessions and communicates directly with the agent to discover its full capabilities, so it takes longer than static recon.

Step 4: Look Around

With recon data, you can:

View configuration files — In the Config tab, pick any file to see its contents.

Browse sessions — In the Sessions tab, see what conversations the agent has had and which projects it's worked on.

Check tools — In the Tools tab, see what MCP servers, skills, or plugins are available to the agent.

Step 5: Create a Session

In the Nodes window, with an agent selected, press Enter to open the session options. You can specify a working directory and toggle YOLO mode; press Enter again to start the chat. See Sessions for the session lifecycle and Terminal UI for the complete chat controls.

Working Directory — where the agent should operate. Affects what files it can see and work with.

YOLO Mode — when enabled, the agent auto-approves all tool calls without asking for confirmation. Use this for automation, but be careful — it will execute whatever the agent decides to run.

Once the session is created, send prompts directly from the chat view.

Step 6: Run an Operation

Operations are predefined tasks you can execute through agents. The Operations window (Ctrl+P) has a third tab, Triggers, for automating when chains fire (see Semantic Operations); this walkthrough only uses Library and Executions. The library starts empty, so let's create a simple one first.

Create Your First Operation

  1. Open the Operations window (Ctrl+P) and switch to the Library tab
  2. Press Ctrl+N to create a new operation
  3. Fill in:
    • Name: hello-world
    • Short Name: hello-world — this (not Name) is what builds the category::short_name reference used to run the operation later, and saving is blocked until it's set
    • Category: test
    • Description: A simple test operation
    • Prompt: Say hello and tell me what directory you're currently in.
    • Mode: one-shot
    • Timeout: 60
    • YOLO: leave off (toggle on later to auto-approve tool calls for this operation)
  4. Save the form with Ctrl+S

Run It

  1. In Library, select test::hello-world and press Ctrl+R
  2. Choose your node and agent
  3. Confirm the operation reference test::hello-world

The operation executes through your agent. Watch the output in real-time in the Executions tab — you'll see the agent's response appear as it completes.

Operation Modes

  • One-shot - sends the prompt directly to the agent and returns the response
  • Agent - uses an orchestrating LLM to run multi-turn interactions with the target agent (useful for complex tasks)

For more complex workflows, you can chain multiple operations together. See Semantic Operations for details.

Step 7: Enable Interception (Optional)

To see the traffic between the agent and its LLM backend:

Privilege required: The node must run as root on Linux or as an elevated administrator on Windows before interception can be enabled.

  1. In the Nodes window (Ctrl+L), select your node
  2. Press i and confirm — the TUI auto-picks the method by node OS (TPROXY on Linux, VPN on Windows); macOS and other platforms aren't supported

Praxis supports four interception methods in total — Proxy (system proxy settings), VPN (TUN adapter for packet-level routing), Hosts (hosts file), and TPROXY (kernel-level redirection, the default/recommended method on Linux) — but the TUI's auto-pick only ever chooses TPROXY or VPN. To set a method explicitly (e.g. Proxy or Hosts), use the non-interactive CLI instead: praxis intercept enable <node-prefix> --method <proxy|vpn|hosts|tproxy>.

Open the Intercept window (Ctrl+T) to watch captured traffic in the Traffic tab. You can see:

  • Full request/response bodies
  • Prompts and completions
  • Tool calls and results

See Interception for setup and scope, and Terminal UI for traffic-view controls.

What's Next?

Nodes & Agents

Understanding how Praxis organizes nodes and agents is key to using the platform effectively.

Nodes

A node represents a system running the Praxis node binary. When you deploy a node to a target machine, it:

  1. Connects to RabbitMQ
  2. Registers with the service
  3. Fingerprints installed AI agents
  4. Begins listening for commands

Node Identity

Each node gets a unique ID generated on first run. This ID persists across restarts, so the service recognizes when a node reconnects.

The node also reports:

  • Machine name - hostname of the system
  • OS details - operating system and version
  • Agent list - discovered AI agents
  • Privileged status - whether the node is running as root/admin

Superuser Mode

When the node runs as root, it can operate as different users based on the selected working directory. Selecting a working directory owned by another user will cause agent sessions to run as that user (with the appropriate HOME environment variable set).

This is a Linux/macOS mechanism only (it switches the process's Unix user based on file ownership). It has no equivalent on Windows: an admin-elevated Windows node always runs agent sessions as its own Windows user, regardless of working directory ownership.

Note: Full superuser support is still under development. Users may notice unexpected behaviour when running sessions as different users from a root node. If you encounter issues, try running the node as the target user directly instead.

Privileged Status

Each node reports whether it is running with elevated privileges. On Linux/macOS this means running as root (UID 0); on Windows this means running as an elevated administrator.

Privileged nodes display a priv badge in the praxis TUI. Some features — particularly interception methods that modify system-level configuration (VPN, Hosts, TPROXY) — require elevated privileges. The TUI rejects an interception enable request for a non-privileged node.

Node List

Open the Nodes window (Ctrl+L) in the praxis TUI to see all connected nodes. Select a node to view its details and agents.

Bridge Nodes

In addition to deployed nodes, Praxis supports bridge nodes -- virtual nodes created when Claude Code connects directly to the service using the Claude Bridge. Bridge nodes appear in the TUI alongside regular nodes but have some differences:

  • They only support sessions (no interception, recon, or terminal)
  • They are ephemeral -- they disappear when Claude disconnects
  • Sessions are automatically active in YOLO mode
  • The node type shows as claude-ccrv1 or claude-ccrv2

Bridge nodes are created by enabling the Claude Bridge in Settings and launching Claude Code with the appropriate environment variables. See Claude Bridge for setup details.

Removing Nodes

Removing an online deployed node sends it a graceful shutdown request, which cancels active work, restores interception settings, and exits the node process before the service removes it from the list. Start the node binary again to reconnect it.

Removing an offline or stale node only clears it from the service's tracking, so a future intentional restart can register normally.

Resetting Nodes

You can reset a node to cancel all in-flight operations and return it to a clean state. Reset will:

  • Cancel all running transactions (prompts, recon, etc.)
  • Drop every live ACP session and its per-session Lua VM
  • Close any terminal session
  • Disable interception and restore system settings
  • Re-register the node with the service

In the TUI, open Nodes with Ctrl+L, select the node, and press Ctrl+R to request a reset. The same action is available through the CLI command node reset <id> or the MCP tool node_reset. The node briefly goes offline during reset and comes back with fresh state. Clients drop their local entries for the reset node immediately and re-pull session/list after a short grace period so the Active Sessions overlay reflects reality.

Agents

Agents are the AI assistants detected on each node. When a node fingerprints successfully, you'll see agents like:

  • Antigravity CLI - Google's terminal AI-agent interface (agy)
  • Claude Code - Anthropic's CLI assistant
  • Claude Desktop - Anthropic's desktop app (Windows only)
  • Codex CLI - OpenAI's CLI assistant
  • Cursor Agent - Cursor's background agent CLI (Linux only)
  • Droid CLI - Factory's CLI coding agent
  • Gemini CLI - Google's CLI assistant
  • M365 Copilot - Microsoft 365 Copilot (Windows only)
  • Pi Coding Agent - minimal terminal coding harness (pi)

See Agent Connectors for the full connector list and platform notes.

Agent Selection

In Nodes (Ctrl+L), press Enter or to focus the agent pane, then use / to select an agent. Recon and new sessions target that selection. A node can host concurrent sessions across any combination of agents; selection is a TUI convenience, not a routing constraint. Recon is agent-scoped (_praxis/recon is called with the agent's short_name), and each session explicitly names its connector via _meta.praxis.connector on session/new.

Agent States

Fingerprinted — the agent was detected but no session is open.

Session Active — one or more live sessions exist. The TUI shows a LIVE indicator and, when applicable, a YOLO tag. Open the Active Sessions overlay with Ctrl+W to resume or discard a session; see Terminal UI for its controls.

Working with Nodes and Agents

Typical Workflow

  1. Deploy node to target system
  2. Select node in the praxis TUI's Nodes window (Ctrl+L)
  3. Check agents that were fingerprinted
  4. Focus the agent pane (Enter or ) and select an agent
  5. Run recon to see what the agent knows
  6. Create session for interactive use

Multiple Nodes

When you have multiple nodes, select one in the Nodes list with /. Operations target the selected node/agent, and traffic interception is per-node.

Refreshing

The service periodically requests updates from nodes. If the installed agents change, restart the node or use its reset action so it fingerprints again.

Agent Capabilities

Different agents support different features:

FeatureClaude CodeClaude BridgeClaude DesktopAntigravityCodexCursorDroidGeminiM365 CopilotPi
Static Recon-
Semantic Recon-
Sessions✓ (ACP)✓ (ACP)
Config Editing--
MCP Discovery---
Traffic Intercept----

Codex traffic interception is not yet supported; implementation is tracked in issue #259.

Troubleshooting

Node not appearing

  • Check RabbitMQ connection from the node
  • Verify PRAXIS_RABBITMQ_URL is correct
  • Look at node logs for errors

Agent not fingerprinted

  • Ensure the agent is installed and configured
  • Check that config files exist in expected locations
  • Verify the agent binary is in PATH

Agent disappeared

  • The agent may have been uninstalled
  • Config files may have moved
  • Try refreshing the node

Can't select agent

  • Ensure the node is connected
  • Check that fingerprinting succeeded
  • Look for errors in the node logs

Reconnaissance

Reconnaissance discovers what an AI agent can do-its tools, configuration, and history. This is your window into understanding an agent's capabilities before interacting with it.

Running Recon

From the Nodes window (Ctrl+L), navigate into the detail pane with , select an agent with /, and press r to open the recon overlay. Static recon runs automatically when no cached result is available; press r to refresh it. Press Ctrl+U for deeper semantic recon, which requires a Semantic Parser LLM.

The overlay is a hierarchical browser (similar to a connectors/MCP picker): group headers expand and collapse, leaf items open a detail pane on the right, and a filter bar narrows the tree.

Three tabs:

  1. Config — config files grouped by type (settings, instructions, …)
  2. Tools — MCP servers (with nested tools), skills, and internal tools
  3. Sessions — conversation history grouped by project path

See Terminal UI for the complete recon keybindings and mouse controls.

On first open, the TUI checks the service cache. If no recon data is stored, it triggers an ACP _praxis/recon request on the node and polls about every 1.5 seconds until data arrives (~90-second timeout). Cached data is displayed instantly on re-open.

Tree layout

  • Tools: MCP Servers → server → tools; peer sections for Skills and Internal. Servers show transport, tool count, and a status badge ([ok] / [empty]).
  • Config: groups by config_type (path suffixes like project_instructions: /home/... collapse under a single project_instructions node); expand a group to pick a file and view contents (fetched on select).
  • Sessions: groups by project context_path; session rows show message count and relative time (2h ago).

What Recon Discovers

Tools

Tools are the capabilities available to the agent. This includes MCP servers (external tool integrations), internal/built-in tools (like file operations, command execution, web browsing), and any extensions or plugins the agent supports. Recon discovers what tools are available, how they're configured, and what parameters they accept.

Configuration

Config files reveal how the agent is set up. This includes settings files (model preferences, permissions, API configurations), tool/server definitions, and instruction files like CLAUDE.md or similar that influence agent behavior. Recon identifies these files and makes their contents viewable and often editable.

Sessions

Session history shows past conversations. Recon discovers session files containing conversation transcripts, project contexts, and timestamps. It also identifies project paths where the agent has been used, giving you visibility into recent activity and what the user has been working on.

Static vs Semantic Recon

Static Recon

Fast discovery based on file parsing:

  • Reads known config file locations
  • Parses JSON/YAML configurations
  • Lists files and directories
  • No LLM required

Best for: Quick overview, checking configuration

Semantic Recon

In the recon overlay, press Ctrl+U to run semantic recon. This performs deeper analysis using an LLM:

  • Parses complex configurations
  • Extracts tool definitions from text
  • Identifies capabilities from session transcripts
  • Creates sessions and communicates directly with the agent
  • Understands context

This takes longer than static recon because it actually interacts with the agent to discover its full capabilities.

Best for: Full capability discovery, understanding what tools do

Semantic recon requires the Semantic Parser LLM to be configured. Choose a model that balances speed and capability - multiple parsing calls may be made so fast inference helps, but the model also needs to be capable enough to extract meaningful information from complex configurations.

Querying Stored Recon Data

After running recon, the results are stored in the service database. You can query specific sections without re-running recon:

MCP tools:

  • recon_list - list stored recon data (section: all/sessions/tools/projects/configs)
  • recon_config_read - read config file content
  • recon_session_read - read session file content
  • recon_config_grep - grep config files with regex
  • recon_session_grep - grep session files with regex

These are useful for quick lookups and for AI agents that need to browse specific recon data without triggering a full scan.

Using Recon Data

View Config Files

Select a config leaf in the TUI tree to view its contents. The detail pane shows:

  • File path
  • Full contents
  • Syntax highlighting (JSON, YAML)

Edit Configurations

Some configurations can be edited directly (like Claude's config.json or MCP server definitions). Select the config leaf and press Ctrl+E to open it in $VISUAL or $EDITOR; save and exit the editor to write changes to the target. See Terminal UI for the complete recon controls.

This is useful for exploring the offensive impact of configuration changes - adding MCP servers, modifying permissions, changing model settings, or injecting tool configurations.

Caution: Editing configs can break the agent if done incorrectly. The changes persist until the user or agent modifies them again.

View Session History

Select a session leaf to view the conversation:

  • Full transcript with prompts and responses
  • Tool calls and results
  • Timestamps

This reveals:

  • What projects the user worked on
  • What questions they asked
  • What files were accessed
  • Sensitive information mentioned

Tool Discovery Details

MCP Servers

MCP (Model Context Protocol) servers extend agent capabilities. Recon discovers server definitions including stdio commands and arguments, SSE endpoints, and environment variables. It also attempts to connect to each MCP server to pull out the actual tools it provides - giving you visibility into what external capabilities the agent has access to and potential attack surface.

Note that if an MCP server requires specific authentication or environment setup, the tool discovery connection may fail. Praxis does its best to replicate the agent's environment but some servers may not respond.

Internal Tools

Semantic recon discovers built-in agent tools by creating a session and asking the agent directly about its capabilities. The response is then passed through the semantic parser to extract structured tool definitions.

This approach has some pitfalls: the agent may refuse to disclose its tools, provide incomplete information, or the parser may fail to extract tools from the response. The prompt used to ask the agent is defined in the agent connector code and can be customized if needed for better results with specific agents.

Understanding available tools helps you craft effective prompts for operations.

Best Practices

Start with Static

Run static recon first-it's fast and gives you the lay of the land. Then run semantic recon for deeper understanding.

Check Session History

Session history often contains valuable information:

  • API keys mentioned in prompts
  • File paths discussed
  • Security-relevant conversations

Note Interesting Tools

Pay attention to powerful tools:

  • Database access
  • File system access
  • Network capabilities
  • Code execution

These are your leverage points for operations.

Compare Before/After

After modifying configs, run recon again to verify changes took effect.

Troubleshooting

No recon data

  • Ensure agent is fingerprinted
  • Check that config files exist
  • Verify node has read permissions

Semantic recon fails

  • Check Semantic Parser LLM is configured
  • Verify API key is valid
  • Look for errors in service logs

Missing MCP servers

  • Some agents don't use MCP
  • Try semantic recon for deeper discovery

Sessions

Sessions let you interact with AI agents in real-time. When you create a session, Praxis spawns the agent process on the target node and gives you a direct communication channel.

Creating a Session

From the Nodes window (Ctrl+L) in the praxis TUI, with an agent selected:

  1. Press Enter on the selected agent to open the session options
  2. Optionally enable YOLO Mode and pick a working directory
  3. Press Enter to confirm, then wait for the session to initialize

The selected connector creates an isolated session on the target node.

Session Interface

The chat view shows a conversation:

  • Your messages and agent responses interleave in the transcript
  • Responses are rendered as markdown with syntax highlighting

Type in the input field and press Enter to send a prompt.

YOLO Mode

By default, agents require confirmation before executing potentially dangerous actions. YOLO mode auto-approves everything:

  • File operations proceed without confirmation
  • Commands execute immediately
  • Tool calls run automatically

Use YOLO mode when you want uninterrupted operation execution. Be aware that this removes safety guardrails-the agent will do whatever you ask without asking first.

Session Context

Sessions can be created with context:

Working Directory - The directory where the agent operates. This affects file paths and command execution. When running semantic operations or chains from an agent with an active session, the session's working directory is used.

Prompt Timeout - Maximum time in seconds a single prompt can run before the agent process is killed. Defaults to the service-wide prompt_timeout_secs setting (600 seconds). Can be overridden per-session using the --timeout (-T) flag in the CLI.

Session ID - A unique identifier for tracking the session. Used internally for message routing.

What Happens During a Session

Clients (the praxis TUI, external ACP tools) never talk to the node directly. Each prompt is an Agent Client Protocol (ACP) JSON-RPC frame that travels client → RabbitMQ → service → RabbitMQ → node. The node runs a single ACP server that multiplexes all its connectors; the target connector is selected per-session via _meta.praxis.connector on session/new, and subsequent frames for the returned sessionId are routed by the service proxy automatically.

When you send a prompt:

  1. session/prompt is forwarded to the node that owns the session
  2. The node's per-session Lua VM handles the prompt — spawning a one-shot non-interactive subprocess (claudecode, codex), driving the connector via Chrome DevTools Protocol (m365-copilot), or talking to the connector's embedded ACP subprocess (cursor, gemini)
  3. Streaming updates (session/update notifications) flow back as the agent generates text, calls tools, and builds plans
  4. The final session/prompt response carries a stopReason (end_turn or cancelled)

Streaming Sessions (ACP)

All sessions are wrapped in ACP externally, but for agents that natively speak ACP inside the node (currently Cursor and Gemini) you also get typed streaming updates end-to-end. Regardless of the underlying transport, session/update notifications relay:

  • Text chunks — incremental output as the agent generates its response
  • Tool calls — tool name and input displayed as the agent invokes tools
  • Tool results — output from each tool call (with error highlighting)
  • Plans — the agent's execution plan with step status tracking
  • Permission requests — when the agent needs approval for an action (interactive sessions only)
  • Token usage — prompt/completion token counts updated in real time

Cancellation goes through session/cancel (a JSON-RPC notification, no response) — Ctrl+C in the TUI sends it. The in-flight session/prompt then resolves with stopReason: "cancelled" and any partial output is preserved in the conversation history.

Session IDs

Sessions created on a node (via the node's ACP server) are raw UUIDs. Sessions hosted directly on the service — the orchestrator, MCP-driven sessions, and external ACP bridges — are prefixed by caller type so a client can filter the orchestrator session list to its own entries:

  • CLI_ — created by the TUI's orchestrator
  • ACP_ — created by an external ACP client

Session Messages

The TUI tracks messages per session:

  • Messages persist while the session is active
  • Conversation history shows the full exchange

Ending a Session

From a chat, Ctrl+C cancels a running prompt or closes an idle session. The Active Sessions overlay also lets you discard a selected session. This sends session/close to the node, which drops the per-session Lua VM and any owned subprocess. Only the targeted session is affected — any other live sessions on the same connector keep running. See Terminal UI for the complete session controls.

Sessions and Operations

Semantic operations always create their own dedicated session. When an operation runs it calls session/new, executes, and then closes. Because each ACP session owns its own Lua VM (and, where applicable, its own ACP subprocess), operations run concurrently with interactive sessions on the same agent without interfering.

Bridge Sessions

When Claude Code connects to Praxis via the Claude Bridge, a session is created automatically as part of the connection. Bridge sessions differ from regular sessions:

  • The session starts immediately when Claude connects (no manual creation needed)
  • Permissions are always bypassed (YOLO mode) since the bridge sets bypassPermissions during handshake
  • Only one prompt can be in-flight at a time
  • Closing the session sends an end_session request to Claude and terminates the connection
  • The virtual node is deregistered when the session ends

Bridge sessions are otherwise used the same way -- you can send prompts, run operations, and include them in chains.

Multiple Sessions

A single node can host any number of concurrent ACP sessions across any combination of connectors. Each session/new returns a fresh sessionId, and every session gets its own isolated per-session Lua VM built from bytecode compiled once at connector-load time, so there is no global state shared between sessions even when they target the same connector.

Listing and resuming

The TUI refreshes its view of live sessions by calling session/list on each connected node. It does this on first connect, when you open the Nodes window (Ctrl+L), and ~1.5s after a node reset. Any server-side sessions the TUI hadn't yet seen — for example a session left alive across a TUI restart — are merged into the local sessions list and become resumable.

In the TUI

Ctrl+W in the Nodes window opens the Active Sessions overlay, which lists every live session with node, agent, session ID preview, status, and creation time. From a chat, Esc or Ctrl+W pauses the session without closing it; resume it from the overlay. The status bar shows an N sessions counter when any concurrent sessions are live. The Terminal UI page is the canonical reference for resume, discard, and chat shortcuts.

Troubleshooting

Session won't create

  • Check the agent binary exists on the node
  • Verify the node is connected
  • Look at node logs for spawn errors

Messages not appearing

  • Ensure the session is active (check the indicator)
  • Try toggling away and back to the chat view
  • Check the TUI's RabbitMQ connection status (praxis --status)

Session hangs

  • The agent may be waiting for input
  • Check if YOLO mode should be enabled
  • Try sending a simpler prompt

Unexpected responses

  • Remember the agent has full system access
  • Previous conversation context affects responses
  • Try closing and creating a fresh session

Terminal

The terminal feature gives you direct shell access to nodes. This is a full PTY terminal - a separate shell on the target system.

Opening a Terminal

Open Nodes with Ctrl+L, select a node, and press Ctrl+Y to toggle its terminal. The terminal is rendered directly in the TUI and provides ANSI terminal emulation with colors, cursor movement, escape sequences, and scrollback. Press Ctrl+Y again to close it.

See Terminal UI for the canonical Nodes and terminal controls.

What You Can Do

This is a real shell. You can:

  • Run commands on the target system
  • Navigate the filesystem
  • View and edit files
  • Run scripts
  • Check system status

The shell runs as the same user that runs the Praxis node.

Terminal vs Agent Session

These are different things:

TerminalAgent Session
Direct shell accessAI agent interaction
Raw commandsNatural language prompts
System-levelAgent-level
No AI involvedAI processes requests

Use the terminal for direct system work. Use sessions for agent interaction.

Use Cases

Debugging - Check logs, inspect files, verify the node is working correctly.

Preparation - Set up environments, install dependencies, configure the system before running operations.

Manual Operations - Sometimes you just need a shell. The terminal is there when you need it.

Verification - After an operation runs, verify the results directly.

Terminal Persistence

The terminal session persists while you have the panel open. Closing the panel ends the shell session. There's no background persistence-this is an interactive terminal.

Limitations

  • One terminal per client at a time (per node, per connecting client)
  • Runs as the node's user
  • Subject to the node's environment and permissions

Troubleshooting

Terminal won't connect

  • Verify the node is online
  • Check RabbitMQ connectivity
  • Look at node logs

Commands not working

  • Check the node's environment
  • Verify PATH settings
  • Ensure required tools are installed

Display issues

  • Terminal size may need adjustment
  • Some applications may not render correctly
  • Try simpler commands to verify basic function

Interception

Traffic interception lets you see the communication between AI agents and their LLM backends. You can watch prompts being sent, responses coming back, and tool calls being made.

How It Works

┌─────────┐         ┌─────────────┐         ┌─────────────┐
│  Agent  │──HTTPS──│   Praxis    │──HTTPS──│   LLM API   │
│         │         │   Proxy     │         │             │
└─────────┘         └──────┬──────┘         └─────────────┘
                           │
                           ▼
                    ┌─────────────┐
                    │  Captured   │
                    │   Traffic   │
                    └─────────────┘

Praxis acts as a man-in-the-middle:

  1. Installs a root CA certificate
  2. Generates certificates for target domains
  3. Terminates TLS and captures traffic
  4. Re-encrypts and forwards to the real destination

Intercept Targets

The set of domains and URL filters captured by the proxy is configured centrally on the service and pushed to nodes — there is no per-agent hard-coded list. The full list lives as a single TOML virtual file on the service. Each [section] is one intercept target; the section header is the agent_short_name used to route captured traffic to the matching connector.

If multiple enabled targets contain the same domain, Praxis retains every candidate agent instead of assigning the endpoint to whichever target was loaded last. Traffic displays the candidates separated by |; agent filters and agent-scoped rules match each candidate individually. URL capture occurs when any matching target permits the URL.

[claudecode]
domains = ["api.anthropic.com", "a-api.anthropic.com"]
url_pattern = "messages"

[cursor]
domains = ["agent.api5.cursor.sh", "api2.cursor.sh", "cursor.sh"]

Fields per target:

FieldRequiredNotes
domainsyesOne or more hostnames to capture for this target.
url_patternnoOptional regex matched against the request URL path.

Section names cannot contain |, which is reserved for representing ambiguous multi-agent attribution.

Lines starting with # are ignored. To disable a target without deleting it, comment out the entire section. Praxis ships built-in targets for the bundled connectors (Claude Code, Claude Desktop, Cursor, Droid, Gemini, M365 Copilot); the defaults are seeded on first boot.

Managing targets

  • TUI: Settings (Ctrl+S) → Intercept tab. The tab shows the currently-parsed targets and two actions:
    • Edit virtual file in $EDITOR — opens the raw TOML in your editor ($VISUAL / $EDITOR, falling back to vi/notepad). Save and exit to send the new contents to the service; parse errors are reported in the status bar and the stored file is left untouched.
    • Reset to built-in defaults — restores the file shipped with Praxis after a confirmation prompt.

Changes take effect immediately: the service broadcasts the parsed list to all connected nodes. If interception is currently enabled on a node, the new list is applied the next time interception is enabled.

Interception Methods

Praxis supports four methods for routing traffic through the proxy. Each has tradeoffs.

Proxy Mode

How it works: Configures system proxy settings so applications route HTTP/HTTPS through the Praxis proxy.

Setup:

  • Linux: Sets HTTP_PROXY and HTTPS_PROXY environment variables
  • Windows: Modifies registry proxy settings

Advantages:

  • Easiest to set up (loopback listener only)
  • Minimal system routing changes

Disadvantages:

  • Only captures HTTP/HTTPS
  • Some applications ignore proxy settings
  • May conflict with existing proxy configuration
  • In Praxis, enabling interception still requires a privileged node (the Interception capability is only advertised when running as root/admin)

Best for: Quick setup on a privileged node, apps that respect proxy settings

VPN Mode

How it works: Creates a TUN network adapter and routes specific IPs through it at the packet level.

Platform support: Windows and Linux. TPROXY is the recommended Linux default because it avoids userspace packet processing.

Setup:

  1. Intercept domains resolved to IP addresses and conflict preflight runs (no system mutation yet)
  2. TUN device created (wintun on Windows / Linux TUN) only after preflight
  3. Routes added for resolved IPs through the TUN
  4. Packet engine performs NAT to redirect to proxy
  5. Proxy connects to real server, bypassing TUN via interface binding

Internal details:

  • TUN uses IP 10.255.0.1, virtual client uses 10.255.0.100
  • Packet engine maintains NAT table mapping client connections to proxy
  • Proxy bypasses TUN by binding to the real network interface's IP (not 10.255.0.1)
  • Packet engine distinguishes proxy traffic (src != 10.255.0.1) and passes it through

Advantages:

  • Captures traffic from all applications
  • Works even if apps ignore proxy settings
  • More comprehensive coverage

Disadvantages:

  • Requires elevated privileges (admin)
  • More complex setup

Best for: Comprehensive capture on Windows, applications that bypass proxy

Hosts Mode

How it works: Modifies the hosts file to redirect target domains to localhost where the proxy listens.

Setup:

  • Adds entries to /etc/hosts (Linux) or C:\Windows\System32\drivers\etc\hosts (Windows)
  • Flushes DNS cache

Advantages:

  • Simple mechanism
  • Works for static domains
  • No packet-level complexity

Disadvantages:

  • Requires elevated privileges
  • Only works for known domains
  • Doesn't handle DNS load balancing
  • Applications using custom DNS may bypass

Best for: Simple setups with known domains

TPROXY Mode (Linux only)

How it works: Uses iptables TPROXY to transparently redirect traffic to the proxy at the kernel level.

Setup:

  1. Intercept domains resolved to IPv4 addresses before system changes
  2. Policy routing configured to route marked packets to loopback
  3. iptables mangle rules added for the resolved target IPs (mark 0x1)
  4. TPROXY redirects matching packets to the proxy port
  5. IPv6 disabled system-wide only after rule setup succeeds (restored on cleanup)
  6. Proxy uses the accepted socket local_addr (IP_TRANSPARENT) as the real destination
  7. Proxy's outbound connections use bypass mark 0x2 to skip interception rules

Internal details:

  • Uses iptables mangle table with PREROUTING chain
  • Bypass rule: -m mark --mark 0x2 -j RETURN placed before intercept rules
  • Proxy sets SO_MARK=0x2 on outbound sockets to avoid routing loop
  • Policy routing table 100 handles marked packets

Advantages:

  • No TUN device or userspace packet processing
  • Lower overhead than VPN mode
  • Standard Linux networking (works with any kernel supporting TPROXY)
  • Works for all TCP traffic to target IPs

Disadvantages:

  • Linux only
  • Requires elevated privileges (root or CAP_NET_ADMIN)
  • Modifies iptables rules (may conflict with existing firewall)
  • Temporarily disables IPv6 (IPv4 only)

Best for: Linux systems needing efficient kernel-level interception

Privilege Requirements

All interception methods require a privileged node in Praxis: the node only advertises the Interception capability when running as root/admin. VPN, Hosts, and TPROXY also need those privileges for system routing/cert changes; Proxy is still gated the same way even though its listener is loopback-only.

Nodes report their privilege status automatically. In the praxis TUI, press i on a non-privileged node shows an error — you must restart the node with elevated privileges before enabling interception. Privileged nodes display a priv pill in the Nodes window.

Enabling Interception

  1. Open the Nodes window in the praxis TUI
  2. Select your node (must be running privileged — root/admin)
  3. Press i and confirm

The TUI auto-picks a method by OS: TPROXY on Linux, VPN on Windows. Other methods (Proxy, Hosts) are available via the node command path; the default TUI flow uses the OS-appropriate privileged method.

The same operations are available without the TUI:

praxis intercept status
praxis intercept enable <node-prefix> --method tproxy
praxis intercept disable <node-prefix>

The node will:

  • Create and install a root CA certificate
  • Generate leaf certificates for intercept domains
  • Start the proxy server
  • Configure system based on chosen method

View captured traffic in the Intercept window (Ctrl+T). See Terminal UI for its filters, detail pane, rules, and match controls.

Viewing Traffic

Traffic Tab

The Traffic tab shows captured requests:

ColumnDescription
TimeWhen the request occurred
AgentWhich agent made the request
MethodHTTP method (GET, POST)
URLFull request URL
StatusResponse status code

WebSocket traffic is also supported — frames for one connection are grouped by a stable flow id (not merely URL), so concurrent sockets to the same host do not merge in the TUI.

HTTP/2 is captured at the frame level (HEADERS/DATA and control frames relayed). gRPC over HTTP/2 is therefore frame-level capture, not full message reassembly across DATA frames — length-prefixed gRPC messages that span frames may appear fragmented in capture, rules, and search. This is the supported scope for this release (not a temporary gap pending reassembly). Frames group by connection flow + stream in the TUI.

Live list/match streams and traffic log/search/matches responses are metadata-only (bodies stripped). Opening a row issues a TrafficGet for full request/response bodies (lazy load). Captured request/response headers are stored as a map of name→single value (duplicate header names collapse).

Request Details

Select a row and press Enter or to open its details:

Request:

  • Headers (name→single value; duplicate names collapse)
  • Request body (JSON formatted)
  • Content type

Response:

  • Status code
  • Headers (name→single value; duplicate names collapse)
  • Response body (JSON formatted)

For LLM APIs, you'll see:

  • The prompts being sent
  • Tool call requests
  • Model responses
  • Token usage

Protocol Support

HTTP/1.1

Standard HTTP traffic is captured as separate send and receive entries. Send rules inspect only request fields; receive rules inspect only response fields.

WebSocket

WebSocket upgrades require HTTP 101 plus both Upgrade: websocket and a Connection: upgrade token (plain and TLS paths). Individual frames are captured with method suffixes like WS_TEXT#<flow> used as internal grouping keys (the TUI may hide the #flow suffix). Grouping is per flow id, not solely by URL.

HTTP/2 and gRPC

The proxy provides frame-level HTTP/2 interception for services using HTTP/2 (including gRPC streaming):

Detection: TLS ALPN is authoritative for HTTP/2, and the complete connection preface (PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n) is then read and validated without consuming the first SETTINGS frame. Praxis prefers HTTP/1.1 during its client handshake for broad origin compatibility; when a client requires HTTP/2, the origin must also negotiate h2 or the tunnel is rejected instead of relaying bytes under mismatched protocols.

Captured Frames:

  • H2_HEADERS - Request/response headers, HPACK-decoded into readable key/value pairs (including the :method, :path, and :status pseudo-headers)
  • H2_DATA - Request/response body data

Frame Relay: All frame types are forwarded bidirectionally:

  • SETTINGS, WINDOW_UPDATE (flow control)
  • PING (keep-alive)
  • RST_STREAM (stream reset)
  • GOAWAY (connection close)

gRPC Streaming: Bidirectional DATA frames are relayed and captured, but each DATA frame is treated independently for decompression/capture. Supported scope for this release is frame-level capture, not full gRPC message reassembly across frames.

UI Display: HTTP/2 traffic is grouped by connection flow and stream (method fields use suffixes like H2_HEADERS#<flow>:<stream> as internal keys), showing:

  • Total frame count
  • Send/receive counts
  • Total bytes transferred
  • Individual frames expandable with payload preview

Header Decoding: HEADERS frames are decoded with a stateful per-direction HPACK decoder that tracks the dynamic table across frames and reassembles blocks split across CONTINUATION frames. Decoded headers are shown as key/value pairs, and the :path pseudo-header provides URL context for DATA frames in the same stream. If a header block ever fails to decode, header decoding for that direction is disabled for the rest of the connection rather than showing corrupt output. Header blocks and HPACK dynamic tables are capped at 1 MiB, padded DATA frames exclude padding from captured bodies, and invalid interleaved CONTINUATION sequences disable only capture decoding while frames continue to be forwarded.

Capture completeness: Captured traffic passes through bounded queues on both the node and service. The service persists entries with a bounded worker pool so database and rule processing cannot block node command/status dispatch. If a burst outpaces either queue, entries are dropped rather than blocking the intercepted connection, and Praxis logs that capture is lossy. Forwarding of the live connection is independent of service storage, but local proxy memory bounds (e.g. 64 MiB plain HTTP request/response buffering) can still affect traffic under extreme concurrent load.

Trigger firing and LLM summarization run on a separate capacity budget and are dropped immediately under load (no queueing) without blocking capture persistence.

Clear vs in-flight triggers: Traffic Clear deletes stored rows and advances the service clear-generation so new live batches and TUI state reconcile. Trigger / LLM / external side effects that have already entered the trigger engine for a match may still complete; Clear does not revoke in-flight match side effects. A generation check skips only triggers that have not yet entered the engine when Clear has already advanced.

Cleanup and Recovery

Enable records recovery intent before changing proxy settings, hosts/routes, TPROXY policy rules, or IPv6 sysctls. Disable removes only Praxis-owned rules and retains the recovery file if any cleanup step fails, allowing a retry on the next start.

Failed or cancelled enable whose rollback cannot fully clean up enters CleanupRequired: re-enable is blocked until Disable or node Reset runs force_cleanup successfully. Manager-owned CA/resource handles are retained while cleanup is incomplete; recovery metadata is not overwritten by a fresh enable. Clients surface InterceptStatus.cleanup_required via CLI intercept status; the Intercept TUI window does not list per-node state (fleets can be large — enable/disable lives in the Nodes window).

A node also refuses to proceed while stale cleanup is incomplete or its recovery file cannot be parsed. Async enable phases (DNS, proxy start, packet IP refresh) race an operation cancel token. Host child commands (iptables, netsh, sysctl, cert tools, etc.) run through a bounded runner (output_bounded / 30s default) that kills the process on timeout; timeout is treated as unknown host state and keeps recovery ownership / CleanupRequired rather than claiming Disabled.

Limits

LimitValue
Captured body (stored / live)10 MiB per side
Captured headers (aggregate)1 MiB
WebSocket frame16 MiB
Plain non-CONNECT HTTP buffer64 MiB request + 64 MiB response (buffered, not streaming)
TUI traffic ring buffer2,000 entries
TUI matches buffer2,000 entries
TUI paused pending2,000 entries
TUI body cache64 MiB (insertion-order eviction)
Service traffic-query queue64 jobs, 4 workers
Service intercept-processing queue64 entries, 8 workers
Live broadcast batch queue4,096 (bodies stripped)
CLI live intercept entry/match channels32 batches (drop-when-full, rate-limited logs)

Compatibility and Trust

Traffic request_id: Client traffic log/search/matches/clear/get signals include a client-generated request_id echoed in the matching response so concurrent same-kind queries are not swapped. The field accepts an empty default when older peers omit it (Serde default). Prefer atomic CLI/service upgrades; mixed versions without a unique request_id can still mis-pair concurrent traffic queries.

Node identity on intercept traffic: The service accepts InterceptedTraffic / intercept status when the payload node_id names a registered node. It does not bind the payload to an authenticated transport sender. If multiple nodes share the same RabbitMQ credentials, a node can attribute traffic or status under another registered node id. Treat co-credentialed nodes as fully trusted for attribution, or isolate broker credentials per node.

Clear vs ingest: Traffic clear takes an exclusive barrier shared with ingest, prune, and queries. A generation counter advances on successful clear; pre-clear entries that only reach the DB after clear are dropped. Multi-batch regex/body scans use keyset pagination on id (not OFFSET).

Duplicate header names are preserved when forwarding plain HTTP, but stored capture uses a map and collapses duplicates to a single value per name.

Server-side traffic list/search scans may skip rows under concurrent deletes or pruning (pagination is best-effort without a snapshot transaction). Clear is synchronized against concurrent list/search/get jobs on the service, but ingest/pruning can still insert or delete around a clear — a successful clear does not guarantee a global snapshot of “everything before this request is gone.” Invalid search regex falls back to a literal substring pattern; rules reject invalid regex on create/update.

Traffic Rules

Rules let you match and process specific traffic.

Creating Rules

  1. Open Intercept with Ctrl+T and switch to Rules
  2. Press Ctrl+N to create a rule
  3. Configure:
    • Name - identifier for the rule
    • Pattern - regex (must compile; invalid patterns are rejected)
    • Direction - send, receive, or both
    • Scope - all traffic or specific node/agent
    • Summarization prompt - optional LLM analysis

Matching covers host, URL, method, response status, headers, and UTF-8 bodies (respecting send/receive direction). The TUI rule-form “Recent matches” preview uses the local traffic buffer and only sees bodies that have already been loaded into the detail cache (live rows arrive bodyless).

Rule Matching

Matching runs when traffic is captured. Creating or updating a rule also backfills against the most recent ~500 stored entries so historical body-only patterns appear in Matches without waiting for new traffic.

When traffic matches a rule:

  • Entry is tagged with the rule
  • Matches viewable separately

Semantic Parsing

Rules can include a summarization prompt for semantic analysis. When a rule matches and has a summarization prompt configured, the Traffic Parser LLM processes the matched traffic - extracting prompts, summarizing responses, detecting tool calls, and highlighting key information.

Text bodies up to the configured Traffic Body Limit are passed to the Traffic Parser in full (60 KiB by default). For larger bodies, the analyzer receives the beginning and end with an explicit middle-truncation marker so recent messages and tool results at the end of an LLM request remain visible. Binary bodies are represented by their byte size. Change the limit under Settings (Ctrl+S) → LLM.

Use rules to:

  • Flag specific API calls
  • Track sensitive operations
  • Collect API keys
  • Monitor for specific content

Disabling Interception

In Nodes (Ctrl+L), select the node, press i, and confirm to disable interception. This:

  • Removes the installed certificate
  • Restores proxy settings (if modified)
  • Cleans hosts file entries (if modified)
  • Removes iptables TPROXY rules (if used)
  • Stops the proxy server

Shared IP Passthrough

When multiple domains share the same IP address (e.g., claude.ai and api.anthropic.com both resolve to 160.79.104.10), traffic to non-intercepted domains may route through the proxy.

The proxy handles this transparently:

  1. Extracts SNI (Server Name Indication) from TLS ClientHello
  2. Checks if the domain should be intercepted
  3. For non-intercepted domains, tunnels traffic through without TLS termination
  4. Uses the same bypass mechanisms to connect to the real server

This ensures non-intercepted domains continue to work normally even when sharing IPs with intercepted domains.

Security Considerations

Certificate Trust

The generated root CA must be trusted by the system for HTTPS interception to work. This is done automatically but:

  • Some applications have their own certificate stores
  • Users may notice certificate changes
  • Security tools may alert on unknown CAs

Credential Exposure

Intercepted traffic may contain:

  • API keys in headers
  • Authentication tokens
  • Sensitive prompts and responses

Handle captured data appropriately.

Detection

Interception is not stealthy:

  • Root CA installed in system store
  • System proxy modified (Proxy mode)
  • Hosts file modified (Hosts mode)
  • Network adapter created (VPN mode)
  • iptables rules modified (TPROXY mode)

This tool is designed for research, not covert operations.

Troubleshooting

Traffic not appearing

  • Verify interception is enabled
  • Check the agent uses intercepted domains
  • Try a different interception method
  • Ensure proxy certificate is trusted

Certificate errors

  • Some apps have pinned certificates
  • Node.js: Set NODE_EXTRA_CA_CERTS
  • Python: Set REQUESTS_CA_BUNDLE
  • Browsers may need manual cert import

VPN mode fails

  • Requires Administrator privileges on Windows or root/CAP_NET_ADMIN on Linux
  • Check for conflicting VPN software

TPROXY mode fails

  • Linux only
  • Requires root or CAP_NET_ADMIN capability
  • Verify iptables is available: which iptables
  • Check for conflicting mangle rules: iptables -t mangle -L
  • Ensure route_localnet can be enabled on loopback
  • Check policy routing: ip rule list and ip route show table 100

IPv6 connectivity issues during interception

TPROXY mode temporarily disables IPv6 system-wide (net.ipv6.conf.all.disable_ipv6=1) because:

  • TPROXY rules only handle IPv4 traffic
  • IPv6 traffic would bypass interception

IPv6 is automatically restored when interception is disabled or when the node process next starts and finds recovery state. If automatic cleanup repeatedly fails, restore it manually to the value appropriate for the host:

sudo sysctl -w net.ipv6.conf.all.disable_ipv6=0

Performance issues

  • Large traffic volumes can slow things down
  • Consider filtering to specific domains
  • Use rules to reduce stored traffic

Log Query

The Log Query feature provides a KQL-like query interface for exploring and correlating data across Praxis virtual tables (captured traffic, events, recon results, nodes, agents, operation history, etc). The syntax is inspired by Kusto Query Language but only a subset of KQL is implemented — not all features or functions from the full Kusto specification will work. Write queries in the code editor (or press Ctrl+E to edit in $VISUAL / $EDITOR), execute them with Ctrl+R (Ctrl+Enter is kept as an alias), and browse paginated results.

Available Tables

AgentLogs

Discovered agents across all nodes (in-memory).

ColumnDescription
timestampLast update time
node_idNode identifier
agent_short_nameAgent short name
agent_nameAgent display name
versionAgent version (if known)

EventLogs

Centralized application log entries from service and nodes. Requires application_logs_enabled to be set to true in settings.

ColumnDescription
timestampWhen the log entry was recorded
sourceOrigin category: "service" or "node"
source_idInstance identifier (e.g. node UUID; empty for service)
levelLog level: error, warn, info, debug, trace
targetLog target/module (may be null)
messageLog message text

NodeLogs

Currently connected nodes (in-memory).

ColumnDescription
timestampLast update time
node_idNode identifier
machine_nameMachine hostname
os_detailsOperating system details
intercept_activeWhether interception is active

ReconLogs

Summary of reconnaissance results per node+agent.

ColumnDescription
timestampWhen recon was performed
node_idNode identifier
agent_short_nameAgent short name
is_semanticWhether this was a semantic recon
mcp_server_countNumber of MCP servers discovered
skill_countNumber of skills discovered
internal_tool_countNumber of internal tools discovered
config_countNumber of config items discovered
session_countNumber of sessions discovered
project_path_countNumber of project paths discovered

ReconSessionLogs

Sessions discovered during reconnaissance.

ColumnDescription
timestampWhen recon was performed
node_idNode identifier
agent_short_nameAgent short name
session_idSession identifier
context_pathProject/context path
last_modifiedWhen the session was last modified
message_countNumber of messages in the session

ReconToolLogs

Individual tools discovered during reconnaissance (MCP tools, skills, internal tools).

ColumnDescription
timestampWhen recon was performed
node_idNode identifier
agent_short_nameAgent short name
tool_typeType: "mcp", "skill", or "internal"
server_nameMCP server name (null for skills/internal)
tool_nameTool name
tool_descriptionTool description
transportMCP transport type (null for skills/internal)

SemanticOperationChainLogs

Chain execution history, including per-element state and final outputs. The elements and outputs columns contain JSON — use contains() to search within them.

ColumnDescription
timestampWhen the chain execution was created
execution_idChain execution identifier
chain_idChain definition identifier
chain_nameChain display name
node_idNode that executed the chain
agent_short_nameAgent that executed the chain
statusExecution status: Queued, Running, Completed, Failed, Cancelled
elementsPer-element execution state (JSON)
outputsFinal outputs from termination elements (JSON)
started_atWhen execution started
ended_atWhen execution ended (null if still running)

SemanticOperationLogs

Semantic operation execution history, including results and summaries. The operation_spec column contains the full operation definition as JSON — use contains() to search within it.

ColumnDescription
timestampWhen the operation was created
operation_idOperation identifier
node_idNode that executed the operation
agent_short_nameAgent that executed the operation
statusOperation status: Queued, Running, Completed, Failed, Cancelled
operation_specFull operation specification (JSON)
start_timeWhen the operation started
end_timeWhen the operation ended (null if still running)
summaryBrief summary of actions taken
resultActual findings/data/output
chain_execution_idParent chain execution ID (null if standalone)

ToolkitActionsLog

Toolkit tool execution history.

ColumnDescription
timestampWhen the action was executed
idAction ID
execution_idExecution identifier
tool_nameTool name
actionAction performed
statusAction status
node_idNode identifier
agent_short_nameAgent short name
session_idSession identifier
details_jsonAction details as JSON

TrafficLogs

Intercepted HTTP traffic stored in the database.

ColumnDescription
timestampWhen the traffic was captured
traffic_idTraffic entry ID (join key for TrafficMatchLogs)
node_idNode that captured the traffic
agent_short_nameAgent associated with this traffic
intercept_methodMethod used (proxy, vpn, hosts, tproxy)
directionsend or receive
methodHTTP method (GET, POST, etc.)
urlFull URL
hostHost/domain
request_headersRequest headers as JSON
request_bodyRequest body as text
response_statusHTTP response status code
response_headersResponse headers as JSON
response_bodyResponse body as text

TrafficMatchLogs

Traffic that matched intercept rules, joined with traffic details.

ColumnDescription
timestampWhen the match occurred
traffic_idID of the matched traffic entry (join key for TrafficLogs)
node_idNode that captured the traffic
agent_short_nameAgent associated with this traffic
rule_idID of the matching rule
rule_nameName of the matching rule
summaryLLM-generated summary (if rule has summarization prompt)
methodHTTP method
urlFull URL
hostHost/domain
directionsend or receive
response_statusHTTP response status code

Supported KQL Operators

OperatorDescriptionExample
whereFilter rowsTrafficLogs | where host contains "openai"
projectSelect columnsTrafficLogs | project timestamp, url, host
project-awayRemove columnsTrafficLogs | project-away request_body, response_body
sortSort rowsTrafficLogs | sort timestamp
take / limitLimit rowsTrafficLogs | take 50
topTop N by columnTrafficLogs | top 10 by timestamp
extendAdd computed columnsTrafficLogs | extend url_length = strlen(url)
countCount rowsTrafficLogs | count
distinctUnique valuesTrafficLogs | distinct host
summarizeAggregateTrafficLogs | summarize count() by host
joinJoin two tablesTrafficLogs | join (TrafficMatchLogs) on traffic_id

Join supports qualified keys when column names differ between tables:

LeftTable | join (RightTable) on $left.col_a == $right.col_b

Supported Expressions

  • Comparisons: ==, !=, <, >, <=, >=
  • Logical: and, or
  • String functions: contains, startswith, endswith, has, strlen, tolower, toupper
  • Null checks: isnotempty(), isnull(), isnotnull(), isempty()
  • Aggregations (in summarize): count(), sum(), avg(), min(), max(), dcount()
  • Type conversion: tostring(), toint(), tolong()

Example Queries

// List recent traffic
TrafficLogs | take 20

// Find traffic to a specific host
TrafficLogs | where host contains "api.openai.com" | project timestamp, method, url, response_status

// Count traffic by host
TrafficLogs | summarize count() by host

// List all connected nodes
NodeLogs

// Find available agents
AgentLogs | where available == true

// Find all MCP tools across agents
ReconToolLogs | where tool_type == "mcp" | project agent_short_name, server_name, tool_name

// Correlate traffic matches with rules
TrafficMatchLogs | project timestamp, rule_name, url, summary | take 50

// Join traffic with matches to see matched URLs with rule names
TrafficLogs | join (TrafficMatchLogs) on traffic_id | project timestamp, url, rule_name, summary

// Find traffic with large responses
TrafficLogs | where response_status == 200 | project timestamp, url, host | take 100

// View recent error logs
EventLogs | where level == "error" | take 50

// Count log entries by source
EventLogs | summarize count() by source

// List completed operations with results
SemanticOperationLogs | where status == "Completed" | project timestamp, agent_short_name, summary, result | take 50

// Find failed operations
SemanticOperationLogs | where status == "Failed" | project timestamp, operation_id, agent_short_name, result

// Count operations by status
SemanticOperationLogs | summarize count() by status

// Find operations that are part of a chain
SemanticOperationLogs | where isnotempty(chain_execution_id) | project timestamp, operation_id, chain_execution_id, summary

// List chain executions
SemanticOperationChainLogs | project timestamp, chain_name, status, outputs | take 20

// Find completed chains with their outputs
SemanticOperationChainLogs | where status == "Completed" | project timestamp, chain_name, outputs

Query Execution

SQL Pushdown

Tables backed by the database (EventLogs, TrafficLogs, TrafficMatchLogs, SemanticOperationLogs, SemanticOperationChainLogs) benefit from automatic SQL pushdown. When the executor encounters leading where and take/limit operators in a query pipeline, it translates KQL expressions directly into SQL WHERE clauses with parameterized queries. This means the database handles filtering before rows are loaded into memory, enabling efficient queries over large datasets.

The following KQL constructs are translated to SQL:

  • Comparisons: ==, !=, <, >, <=, >= become SQL comparison operators
  • Logical: and, or become SQL AND/OR
  • String functions: contains/has become LOWER(col) LIKE '%value%', startswith becomes LIKE 'value%', endswith becomes LIKE '%value'
  • Null checks: isnull()/isempty() become IS NULL OR = '', isnotnull()/isnotempty() become IS NOT NULL AND != ''
  • Case functions: tolower(), toupper() become SQL LOWER(), UPPER()
  • Utility: strlen() becomes LENGTH(), tostring() becomes CAST(... AS TEXT), toint()/tolong() become CAST(... AS INTEGER), now() binds the current UTC timestamp

User-provided string values in LIKE patterns are escaped to prevent SQL wildcard injection (% and _ are matched literally).

If any expression in the leading where clauses cannot be translated to SQL (e.g. an unsupported function), the executor falls back to fetching all rows with just a LIMIT and applies all filtering in memory. Operators that appear after a non-pushable operator (like project, extend, summarize) always run in memory.

In-memory tables (NodeLogs, AgentLogs) and JSON-expanded tables (ReconLogs, ReconToolLogs, etc.) are always materialized fully and filtered in memory.

Result Limits

Results are capped by the log_query_row_limit setting, which defaults to 10,000,000 rows. This limit can be configured in Settings > Service > Logging & Data. For database-backed tables this cap is applied as a LIMIT in the pushed-down SQL query itself, so the total_count field is already bounded at fetch time rather than reflecting a true uncapped count. Use take or limit to reduce result size for large tables.

KQL Parser

The Log Query feature uses a vendored fork of the kqlparser crate (v0.0.4, Apache-2.0) for parsing KQL syntax. The vendored copy lives in service/src/log_query/parser/ and includes fixes for multiline join expressions and native $left/$right join key syntax. Only the subset of KQL operators and functions listed above are supported; unsupported constructs will return an error.

Orchestrator

The Orchestrator is an interactive AI agent that can autonomously manage nodes, agents, sessions, operations, and chains across the Praxis network. Unlike semantic operations (which run predefined tasks), the Orchestrator is a free-form conversational interface where you give high-level goals and the AI figures out the steps.

Prerequisites

Before using the Orchestrator, you need:

  1. MCP Server enabled — Open Settings with Ctrl+S, select Service, and enable MCP Server. The Orchestrator connects to the MCP server as a client to access all Praxis tools.

  2. Orchestrator LLM configured — Open Settings with Ctrl+S, select LLM, configure a model definition, then assign it to the Orchestrator feature.

If the MCP server is not enabled when you start a session, you'll see an error message directing you to the settings page.

Starting a Session

  1. Open the Orchestrator window with Ctrl+O
  2. Type your goal or question — the session is opened on demand
  3. Press Enter; the Orchestrator connects to the MCP server and fetches available tools

Sessions and State

The service holds no persistent orchestrator state. The TUI keeps one in-flight session against the service; when the TUI disconnects or closes the session, the conversation is dropped server-side.

  • TUI (praxis) — One session per TUI process. The TUI mirrors every turn to ~/.praxis/sessions/<session_id>.json so you can resume later:

    • praxis --continue resumes the most recent saved session.
    • praxis --resume lists saved sessions and prompts you to pick one.

    When resuming, the saved transcript is shown immediately and the prior turns are sent as conversation history with session/new so the model has recent context for the next prompt. History is trimmed to the most recent ~20 entries on every prompt (not just on resume), so very long conversations lose earlier turns rather than growing unbounded.

What It Can Do

The Orchestrator has access to all Praxis MCP tools:

  • Node management — List nodes, select nodes, request info updates
  • Agent control — List agents, select agents, run recon (static and semantic), query stored recon data (sessions, projects, tools)
  • Sessions — Create sessions, send prompts, close sessions
  • Operations — List, run, monitor, and cancel semantic operations
  • Chains — List, run, monitor, and cancel chain workflows
  • Traffic — Search intercepted traffic with regex patterns

Plus two local tools:

  • wait — Sleep for a specified duration (useful when polling operation status)
  • report_plan — Show a step-by-step execution plan with progress tracking

Example Prompts

Simple exploration:

List all connected nodes and their agents

Multi-step task:

Connect to the first available node, select the Claude Code agent, create a YOLO session, and ask it to list the files in the current directory

Operation execution:

Run the recon::system_info operation on all active nodes and report the results

Monitoring:

Check the status of all running operations and cancel any that have been running for more than 5 minutes

Thinking Mode

If a model emits literal <think>...</think> tags in its plain-text output, the Orchestrator splits them out and renders them inline as a distinct segment before the rest of the response. This is plain-text parsing, not an API-level extended-thinking integration — there is no model-capability detection and no collapse/expand toggle; a thinking segment is always shown inline when present. Whether a model emits <think> tags at all depends entirely on the model/provider, not on any Praxis setting.

Plan Tracking

The Orchestrator can break complex tasks into steps and show progress via the report_plan tool. When the AI calls this tool, you'll see a plan panel with step descriptions and their current status (not started, in progress, done).

Concurrent Tool Calls

When the model emits multiple independent tool-call JSON blocks in a single response, the Orchestrator runs them concurrently and returns all results together in one follow-up message before the next model turn. Dependent steps still run across turns (call prerequisites first, wait for results, then continue).

This is useful for fan-out work such as listing nodes and available operations at the same time, or queueing op_run against several nodes in one step.

Token Usage

Token usage is displayed after each LLM call, showing prompt tokens, completion tokens, and totals. This helps monitor costs when using commercial API providers.

Session Controls

Ctrl+C cancels an in-flight inference while keeping the conversation. Type /clear and press Enter to start a fresh conversation. See Terminal UI for the complete Orchestrator controls.

Model Recommendations

The Orchestrator requires a capable model that can follow tool-calling instructions reliably:

Recommended:

  • Anthropic: Claude Sonnet 4 or Claude Opus 4
  • OpenAI: GPT-4o
  • Google: Gemini 1.5 Pro

Not recommended:

  • Smaller/faster models (Haiku, GPT-4o-mini) — these often fail to follow the tool calling format or hallucinate results

How It Differs from Semantic Operations

AspectOrchestratorSemantic Operations
InterfaceInteractive chatPredefined tasks
ScopeFull Praxis networkSingle node/agent
ToolsAll MCP toolssession_prompt only (agent mode)
Use caseAd-hoc exploration, complex multi-node tasksRepeatable, automated tasks

The Orchestrator is best for exploration, debugging, and complex ad-hoc tasks. Semantic operations are better for repeatable workflows that you want to run consistently.

Troubleshooting

"MCP server is not enabled"

Open Settings with Ctrl+S, select Service, and enable MCP Server. The Orchestrator requires the MCP server to function.

"Could not connect to the MCP server"

The Orchestrator connects to the MCP server when the session is created, so a connection problem is reported up front with the reason (rather than surfacing later as an opaque error on your first prompt). If you see this:

  • Verify the MCP server is running in Settings (Ctrl+S) → Service
  • Check that the configured port is not in use by another process
  • Look at service logs for MCP server startup errors
  • If you just enabled the MCP server, give it a moment to bind, then start the session again

Tools not executing

  • Ensure you're using a capable model (see recommendations above)
  • Check the tool execution results for error messages
  • Verify nodes are connected and agents are available

Session disconnects

If the service restarts, its in-memory Orchestrator sessions are lost. The next prompt you send is detected as targeting a lost session, and the TUI automatically starts a fresh session (re-seeding it with the prior transcript) and resends your prompt — no action needed. If recovery can't re-establish the session, use /clear to start a clean one.

/clear uses standard ACP: session/close then session/new. The service keeps a shared MCP connection for the orchestrator, so after the first session of the process, close+new is near-instant (conversation only; no MCP reconnect).

To bring back a prior conversation deliberately, start the TUI with praxis --continue (or --resume): the saved transcript is replayed locally and re-seeded as history for the next prompt.

Help Assistant

The Help Assistant is a documentation-aware chat agent built into the terminal UI. It is seeded with the Praxis documentation and answers natural-language questions about how to use Praxis — features, configuration, workflows, and concepts — without leaving the app.

It is deliberately separate from the Orchestrator: the Orchestrator is an operator that plans and executes campaigns by driving nodes and agents, whereas the Help Assistant is a read-only guide. It can only search the documentation bundled with Praxis and never takes actions on your behalf.

Opening the assistant

Press Ctrl+H from any window to summon the Help Assistant overlay. Press Ctrl+H again, or Esc, to dismiss it. Because the overlay floats above the current window, you can ask a question and return to exactly what you were doing. See Terminal UI for the complete shortcut list.

Every question is sent to the model assigned to the Documentation Helper. The model decides whether the available conversation and screen context are enough to answer directly or whether it should search and read the bundled documentation. The overlay shows a neutral thinking indicator while the model is deciding; when it calls a documentation tool, it instead shows a documentation-specific divider and spinner before the final answer. Tool-turn narration is limited to the first acknowledgement, so a multi-step lookup has a helpful initial response without several repeated partial replies. The terminal renders the common Markdown used in answers: headings, bullet lists, bold text, inline code, block quotes, and fenced code blocks. Closing the overlay while a response is streaming cancels it, so nothing keeps running in the background.

Screen context

When you open the assistant, it captures a short, structured description of the window you were looking at (for example, "the Nodes window, 3 nodes connected") and includes it with your question so answers can be specific to what is on screen. The footer shows the current context source and whether it is included; Ctrl+T toggles it off for the next question.

Only low-sensitivity, structural context is ever included — which window you are on and safe counts. Sensitive data such as session output, intercepted request and response bodies, captured credentials, and log rows is never collected or sent to the model provider.

Configuration

The assistant uses the model assigned to the Documentation Helper feature in Settings (Ctrl+S) → LLM. If no model is assigned, it falls back to the model configured for the Orchestrator, so it works out of the box once any conversational model is configured.

Relevant service configuration keys:

KeyDescription
llm_feature_doc_helperModel definition assigned to the Help Assistant. Falls back to llm_feature_orchestrator when unset.
llm_doc_helper_max_tokensMax response tokens for the Help Assistant (default 4096). Currently has no Settings UI field — raising it requires editing this key directly.

The documentation corpus is embedded into the service at build time, so the assistant's answers reflect the documentation shipped with your Praxis version and require no network access to the docs site.

Semantic Operations

Semantic operations are predefined tasks that run through AI agents. You define what you want to happen in natural language, and Praxis handles the execution.

What's a Semantic Operation?

An operation is a task specification:

  • Name - Identifier for the operation
  • Prompt - What you want the agent to do
  • Mode - How to execute (one-shot or agent)
  • Timeout - How long to wait
  • Max Iterations - Cap on orchestrator/target-agent turns in agent mode (default 5)
  • YOLO Mode - Auto-approve actions

Think of operations as reusable prompts with execution settings.

Execution Modes

One-Shot Mode

Sends a single prompt to the agent and waits for a response.

How it works:

  1. Create a session (if needed)
  2. Send the operation prompt
  3. Wait for the agent to respond
  4. Return the response
  5. Close the session (if we created it)

Best for: Simple tasks, single actions, quick checks.

Agent Mode

Uses an orchestrating LLM to run multi-turn interactions with the target agent.

How it works:

  1. Orchestrator LLM receives the operation prompt
  2. Orchestrator generates a prompt for the target agent
  3. Target agent responds
  4. Orchestrator evaluates and decides next action
  5. Loop continues until complete or max iterations reached

Best for: Complex tasks, multi-step operations, tasks requiring judgment.

The orchestrator is a separate LLM (configured in Settings as "Semantic Ops" LLM) that manages the interaction. It has access to a session_prompt tool to communicate with the target agent.

Model Requirements

Agent mode requires a sufficiently capable model for the orchestrator. The model must be able to:

  • Follow complex multi-step instructions
  • Output tool calls in the correct JSON format
  • Wait for tool results before proceeding
  • Avoid hallucinating results

Recommended models:

  • Anthropic: Claude Sonnet 4 or Claude Opus 4
  • OpenAI: GPT-4o or GPT-4 Turbo
  • Google: Gemini 1.5 Pro

Not recommended for agent mode:

  • Smaller/faster models (Haiku, GPT-4o-mini, Llama 8B) - these often fail to follow tool calling instructions correctly and may hallucinate results
  • Models without strong instruction-following capabilities

If you're seeing issues with tool calling or hallucinated results, try switching to a more capable model.

Agent Mode Architecture

The orchestrator uses a system prompt that defines its behavior:

Prompt Location: service/src/prompts/semantic_op_agent.prompt

The system prompt is embedded at build time using Rust's include_str! macro. This means:

  • Prompts are part of the compiled binary
  • No runtime configuration of prompts is needed or supported
  • Changes require recompilation

The orchestrator prompt is combined with:

  • Tool calling instructions (common/src/prompts/tool_calling.prompt)
  • Task completion instructions (common/src/prompts/task_completion.prompt)

These define the JSON format the orchestrator uses to call tools and signal completion:

{"tool": "session_prompt", "args": {"text": "..."}}
{"complete": true, "summary": "...", "result": true}

result is a boolean success flag, not a text field — put findings, extracted data, or other output in summary instead.

Creating Operations

Operations are stored in the library:

  1. Open Operations with Ctrl+P and select the Library tab
  2. Press Ctrl+N to open the operation form
  3. Fill in the details:
    • Name and description
    • Operation prompt
    • Mode (one-shot or agent)
    • Timeout value
    • Max iterations (agent mode)
    • YOLO mode setting
  4. Save with Ctrl+S

Operations are stored in the database and available across sessions. See Terminal UI for the complete Operations controls.

Running Operations

From the Library

  1. Open Operations with Ctrl+P and select the Library tab
  2. Select the operation and press Ctrl+R
  3. Select the node and agent in the run form
  4. Watch execution in the Executions tab

Monitoring Execution

The Executions tab shows all running and completed operations:

ColumnDescription
NameOperation being executed
Node/AgentWhere it's running
StatusRunning, Completed, Failed, Cancelled
StartedWhen execution began

Select a run and press Enter to focus its details:

  • Full execution output
  • Iteration history (agent mode)
  • Final result or error

Operation Output

Each operation produces output:

One-shot mode - The agent's response to your prompt.

Agent mode - Full transcript of the orchestrator's iterations:

  • Prompts sent to target agent
  • Responses received
  • Orchestrator's reasoning
  • Final result

Built-in Operations

Praxis comes with some predefined operations for common tasks. You can use these as-is or as templates for your own.

YOLO Mode in Operations

When YOLO mode is enabled for an operation:

  • The target agent session is created with auto-approve
  • Actions execute without user confirmation
  • The entire operation runs hands-off

This is useful for automated scenarios but removes safety checks.

Model Override

Operations can specify a different model than the default:

  • Override the Semantic Ops LLM for specific operations
  • Use faster models for simple operations
  • Use more capable models for complex tasks

Cancellation

Running operations can be cancelled:

  1. Open Operations with Ctrl+P and select the Executions tab
  2. Select the running operation and press Ctrl+C
  3. The operation terminates

Cancellation is best-effort-if the agent is mid-action, that action may complete.

Timeouts

Each operation has a timeout:

  • One-shot: Time to wait for agent response
  • Agent mode: Total time for all iterations

When timeout is reached, the operation fails with a timeout error.

Chaining Operations

Operations can be combined into chains for complex workflows. A chain is a graph of operations with connections defining execution order and session groups controlling how sessions are shared.

Visual Chain Builder

The shipped chain builder is part of the praxis TUI. Open Operations with Ctrl+P, select Library, then use Ctrl+Alt+N to create a chain or Ctrl+E to edit the selected chain. It is a terminal canvas with blocks, connections, and properties.

See TUI — chain builder for the canonical keyboard and mouse controls.

Chain Structure

Every chain starts with a Trigger element and must include exactly one Termination. Between them, you build processing workflows using various block types. Incomplete blocks show a ! badge on the canvas.

Element Types

Chains support several element types:

Trigger - Every chain must start with a trigger. The in-canvas trigger element represents manual execution from the Operations library. For automated triggers, see Chain Triggers below.

Operation - Executes a semantic operation from your library. Pick an existing operation by name (picker opens when you add an Operation). The operation runs against the target agent and its output flows to the next element.

Transform - An LLM-powered transformation step. Takes input from the previous element and applies a multi-line prompt to transform it. Useful for extracting specific data, reformatting output, or summarizing information.

GenericPrompt - Sends a multi-line prompt directly to the agent session (not through an orchestrator). Simpler than an operation — just sends the prompt and captures the response.

Memory - Stores or retrieves data by key (mode is store or retrieve). Store passes data through unchanged; retrieve loads a previously stored key.

Loop - Controls retries. max_iterations is the number of times the retry path may run: port r (0) fires on each retry and port x (1) fires once the retries are exhausted. For example, with max_iterations: 1, the Loop takes r once and then takes x on its next visit.

Tool - Invokes a registered toolkit tool (picker lists known tools; params are JSON).

Payload - Emits a stored payload by id.

Termination - Explicit end of the chain (exactly one per chain).

Conditional Connections

Connections between elements can have conditions:

  • Always (default) - The connection always fires when the source completes
  • On Success - Fires only when the source element completes successfully
  • On Failure - Fires only when the source element fails

This enables branching workflows with error handling paths.

Per-Block Configuration

Operation, Transform, and GenericPrompt elements can carry per-block configuration overrides. Of these, only two currently take effect at execution time:

  • YOLO Mode - Enable auto-approve for this element's session
  • Require All Inputs - When disabled, a merge-point element runs as soon as any upstream input arrives (instead of waiting for all branches). Useful in conditional chains where not all paths execute.

Two more fields are exposed in the properties modal and stored with the block, but are not yet read by the executor:

  • Max Runtime - Intended as a timeout in seconds for this specific element
  • Working Directory - Intended to override the working directory

Setting either of the last two currently has no effect on execution.

Building a Chain

New chains open with a connected Trigger → Termination scaffold. Add the processing elements required for the workflow, keep exactly one Termination, and configure the selected element from its properties. Operations can select an operation and optional model; transforms and generic prompts use a prompt; memory, loops, tools, and payloads each expose their relevant fields.

Use the session-group picker on Operations, Transforms, and GenericPrompts to create, reuse, or omit a group. Elements in the same group share a color tick and agent-session context. The TUI chain-builder guide contains the interaction details and complete shortcuts.

Session Groups

Session groups control how agent sessions are managed across chain elements. Elements that interact with agents (Operations, Transforms, GenericPrompts) can be assigned to session groups.

Assigning Session Groups: Open an element's properties, choose a session group, then select none, create a new group, or reuse an existing group id. Elements in the same group share a color indicator on the canvas.

Same Session Group - Elements share an agent session:

  • The first element creates the session
  • Subsequent elements reuse it
  • Session closes after the last element completes
  • Context and state persist between elements

Different Session Groups - Elements get isolated sessions:

  • Each group has its own session
  • Clean separation, no shared context
  • Useful for independent operations

No Session Group - Element gets a fresh session just for itself.

Why Session Groups Matter:

Agent sessions maintain conversation context. If you run an operation that navigates to a directory, the next operation in the same session starts in that directory. Use session groups when:

  • Operations build on each other's state
  • You want to maintain conversation context
  • Sequential steps depend on previous actions

Use separate groups when:

  • Operations should be isolated
  • You want clean slate for each operation
  • Running parallel independent tasks

Chain Execution

When running a chain:

  1. The executor builds a dependency graph from connections
  2. Finds operations with no dependencies (starting points)
  3. Works through ready elements one at a time from a queue, fully awaiting each before starting the next
  4. As each element completes, marks it done and enqueues any newly-ready elements
  5. Repeats until all complete, or the chain ends via Termination

Elements without dependencies on each other become ready at the same time, but execution is currently sequential — the executor does not run them concurrently, so ordering among independently-ready elements depends on queue order rather than true parallelism. Whether a failed element halts downstream progress depends on how its outgoing connections are configured — see Conditional Connections below.

    ┌─────┐
    │Start│
    └──┬──┘
       │
   ┌───┴───┐
   │       │
┌──▼──┐ ┌──▼──┐
│Op A │ │Op B │  ← Both become ready together; run one at a time, not concurrently
└──┬──┘ └──┬──┘
   │       │
   └───┬───┘
       │
    ┌──▼──┐
    │Op C │  ← This waits for both A and B
    └─────┘

Monitoring Chains

Chain executions appear in the Executions tab alongside individual operations. Select one and press Enter to see element status, output, and timing information.

Chain Cancellation

You can cancel a running chain from the Executions tab with Ctrl+C. Cancellation stops queuing new operations and lets running operations complete (or cancels them).

Use Cases

Sequential Operations - Run operations in order, each building on the previous: enumerate capabilities, identify target, execute action, verify result.

Parallel Reconnaissance - Run multiple recon operations simultaneously, then combine results.

Staged Operations - Build up context across operations with shared sessions, maintaining state throughout.

Chain Best Practices

  • Plan session groups carefully - shared sessions maintain context but accumulate state
  • Handle failures - by default a connection fires regardless of success or failure (see Conditional Connections); use On Success/On Failure routing if you need the chain to stop or branch on failure
  • Test incrementally - run individual operations first, then combine
  • Keep chains focused - one chain, one goal

Chain Triggers

Chains can be executed automatically via triggers. While the in-canvas Trigger element represents manual execution, chain triggers are separate configurations that automate when and how a chain fires. Manage them from Operations (Ctrl+P) → Triggers, not inside the chain canvas.

Trigger Types

Scheduled - Fires on a time-based schedule. Two schedule modes are available:

  • Interval - Fires every N minutes (e.g., every 60 minutes). The next fire time is computed from the last fire time.
  • Daily At - Fires once per day at a specific hour and minute (UTC). If the time has already passed today, the next fire is scheduled for tomorrow.

Scheduled triggers can be recurring (fire repeatedly) or one-shot (fire once and then auto-disable).

Intercept Match - Fires when intercepted traffic matches a specific intercept rule. You specify the rule ID, and whenever traffic triggers that rule, the chain executes. Intercept-match triggers have a 60-second debounce window to prevent rapid repeated firings.

New Node - Fires whenever a new node registers with the service. There is a 10-second delay after registration to allow agent discovery to complete before the chain executes.

Creating Triggers

From Operations (Ctrl+P) → Triggers:

  1. Press Ctrl+N to open the trigger form
  2. Select the target chain, trigger type, and schedule/rule settings
  3. Configure the Target Spec (see Flexible Targeting below)
  4. Save with Ctrl+S

The trigger is immediately active once saved. Each chain can have multiple triggers.

Managing Triggers

The Triggers tab shows all configured triggers across all chains. From there you can:

  • See the chain name, trigger type, configuration summary, and target spec for each trigger
  • Toggle the selected trigger with Enter
  • View when a trigger last fired and when it will next fire
  • Edit or delete the selected trigger with the Operations controls documented in Terminal UI

Trigger Engine

The service runs a trigger engine that polls for due scheduled triggers every 30 seconds. When a trigger fires:

  1. The engine loads the chain definition
  2. Resolves the target spec into concrete node/agent pairs
  3. Executes the chain against each resolved target (fan-out)
  4. Updates the trigger's last_fired_at timestamp
  5. For scheduled triggers, computes the next fire time (or disables if non-recurring)

Event-based triggers (Intercept Match, New Node) fire immediately in response to the event rather than on a polling schedule.

Flexible Targeting

By default, chains run against a single node and agent. The TargetSpec system allows chains to target multiple nodes and agents simultaneously using filters.

Target Spec Fields

FieldDescriptionDefault
Node IDsSpecific node IDs to targetEmpty (all nodes)
OS FilterCase-insensitive substring match on the node's OS detailsNone
Agent Short NamesSpecific agent types to targetEmpty (all available agents)
Include Triggering NodeFor event triggers: ensure the node that caused the event is includedOff

When a trigger fires, the target spec is resolved against the current set of registered nodes:

  1. Start with all registered nodes
  2. Filter by specific node IDs (if any specified)
  3. Filter by OS substring (if specified)
  4. For each remaining node, select agents matching the agent filter
  5. Skip agents that are not currently available

If no targets match, the trigger logs a warning and the chain does not execute.

Target Spec Editor

The target-spec editor appears when creating triggers in the chain builder and when using advanced targeting in the run form. It provides:

  • Node multi-select - Pick specific nodes from the connected nodes list, or leave empty for all nodes
  • OS filter - Free text field for OS substring matching (e.g., "Windows", "Linux", "Ubuntu")
  • Agent multi-select - Pick specific agent types, or leave empty for all available agents
  • Include triggering node - Checkbox shown for event triggers (New Node, Intercept Match) to ensure the triggering node is always included even if it would otherwise be filtered out

Fan-Out Execution

When a chain targets multiple node/agent pairs, the executor performs a fan-out: it creates a separate chain execution for each resolved target. Each execution runs independently and appears in the Executions tab.

Advanced Targeting in Run Form

The chain run form includes an Advanced Targeting toggle. When enabled, instead of selecting a single node and agent, you configure a full target spec. This allows manual one-off fan-out runs without needing to set up a trigger.

Troubleshooting

Operation stuck

  • Check if YOLO mode should be enabled
  • Verify the agent session is responsive
  • Try a simpler prompt

Unexpected results

  • Review the full output
  • Check if the prompt is clear enough
  • Consider using agent mode for complex tasks

Timeouts

  • Increase the timeout value
  • Simplify the operation
  • Check if the agent is responding at all

Tool calling not working (agent mode)

Symptoms: The orchestrator outputs tool calls but they don't execute, or execution completes immediately without actually running the tool.

  • Switch to a more capable model - smaller models often fail to follow the tool calling format correctly. Use Claude Sonnet/Opus, GPT-4o, or Gemini 1.5 Pro
  • Check the operation output for malformed JSON in tool calls
  • Verify the model is outputting the correct format: {"tool": "session_prompt", "args": {"text": "..."}}

Hallucinated or fabricated results

Symptoms: The operation completes with results that look plausible but are entirely made up - the orchestrator never actually called the remote agent.

This happens when a model outputs both a tool call AND a completion signal in the same message, fabricating results instead of waiting for the real tool response.

  • Use a more capable model - this is almost always caused by using a model that doesn't follow instructions well
  • Check the full operation output - if you see a tool call immediately followed by a completion signal with results, the model hallucinated
  • Recommended: Claude Sonnet 4+, GPT-4o, or Gemini 1.5 Pro
  • Avoid: Smaller/faster models like Haiku, GPT-4o-mini, or small open-source models for agent mode orchestration

MCP Server

Praxis exposes its capabilities via a Model Context Protocol (MCP) server over streamable-HTTP transport. This server is built into the Praxis service and provides tool access for both external AI agents and the built-in Orchestrator.

Overview

The MCP server serves two purposes:

  1. Orchestrator backend — The built-in Orchestrator connects to the MCP server as a client to access all Praxis tools. This is how the Orchestrator coordinates operations across nodes and agents.

  2. External AI agent integration — Any MCP-compatible AI assistant (Claude Code, Cursor, Windsurf, etc.) can connect to the same server to control Praxis programmatically.

Enabling the MCP Server

The MCP server is controlled via service settings:

  1. Open Settings (Ctrl+S) > Service in the praxis TUI
  2. Toggle MCP Server to turn it on
  3. Configure the port (default: 8585)

The MCP endpoint is available at http://localhost:{port}/mcp.

Note: The MCP server must be enabled for the Orchestrator to function. If disabled, the Orchestrator will display an error directing you to enable it.

When running with Docker, port 8585 is exposed by default. To use a different port:

PRAXIS_MCP_PORT=9090 docker compose up --build

Then update the port under Settings > Service to match.

AI Agent Integration

MCP-compatible AI assistants can connect to the Praxis MCP server to control the entire C2 network. This enables AI agents to discover nodes, run recon, create sessions, execute operations, and search traffic — all through structured tool calls.

Configuration

For any MCP-compatible client, point it at the MCP endpoint:

{
  "mcpServers": {
    "praxis": {
      "url": "http://localhost:8585/mcp"
    }
  }
}

Adjust the host and port to match your deployment. For remote deployments, ensure the MCP port is accessible from the client machine.

Available Tools

The MCP server exposes the following tools:

Node Management

  • node_list — List all connected nodes (includes privileged status)
  • node_select — Get details for a specific node
  • node_reset — Reset a node (cancel operations, close sessions, re-register)

Agent Management

  • agent_list — List agents on a node
  • agent_update — Request agent info refresh

Agents are selected per-session rather than per-node. session_create and the recon tools each take an agent parameter, so the same node can run concurrent sessions against different agents.

Reconnaissance

All recon tools take a node prefix and an agent short-name.

  • recon_run — Run static reconnaissance (node, agent)
  • recon_run_semantic — Run semantic reconnaissance, includes internal tools (node, agent)
  • recon_list — List stored recon data (node, agent, section = all/sessions/tools/projects/configs)
  • recon_config_read — Read config file content discovered by recon (node, agent, optional path)
  • recon_session_read — Read session file content (node, agent, optional path)
  • recon_config_grep — Grep config files with regex (node, agent, pattern, optional paths)
  • recon_session_grep — Grep session files with regex (node, agent, pattern, optional paths)
  • write_file — Write file content

Sessions

  • session_create — Create a new ACP session (node, agent, optional project, yolo). Returns a session_id.
  • session_list — Enumerate active ACP sessions on a node (node). Returns each session's id (full + short), title, and cwd.
  • session_prompt — Send a prompt to a session (node, session_id, prompt)
  • session_close — Close a session (node, session_id)

Operations & Chains

  • op_available — List available operations and chains
  • op_definition — Show the full definition of an operation or chain
  • chain_create — Create a reusable linear chain from existing operations
  • op_create — Create a new operation and persist it to the library. Warning: this writes a real, reusable operation — getting the prompt wrong can cause unintended agent behavior on target systems the next time it runs.
  • op_run — Run an operation or chain
  • op_info — Show full info for an operation or chain execution
  • op_cancel — Cancel a running operation or chain execution
  • op_delete — Permanently remove an operation definition. Warning: this is destructive and cannot be undone.
  • op_list — List tracked operations and chain executions

Chain Triggers

  • trigger_list — List all chain triggers
  • trigger_create — Create a trigger for a chain
  • trigger_delete — Delete a trigger by ID prefix
  • trigger_toggle — Enable or disable a trigger by ID prefix

To automate an operation that is not in a chain yet, create the chain first:

{
  "name": "CI/CD on connect",
  "description": "Run CI/CD discovery whenever a node registers",
  "operations": ["custom::cicd"],
  "category": "custom",
  "timeout": 600
}

chain_create resolves each operation by full name, short name, or display name and connects them in the given order between a manual start element and a termination element. The resulting chain can then be passed by name to trigger_create.

trigger_create accepts scheduled, intercept_match, and new_node trigger configurations. For example, an Orchestrator can make an existing chain run whenever a node registers:

{
  "chain": "CI/CD",
  "trigger": {
    "type": "new_node"
  },
  "target": {
    "agent_short_names": ["codex"],
    "include_triggering_node": true
  }
}

New-node triggers run after a 10-second discovery delay. Target node IDs must be full IDs from node_list; leaving node_ids empty targets all registered nodes. For event triggers, include_triggering_node ensures that the node which caused the event is included even when an explicit node list would otherwise exclude it.

agent_short_names matching is exact and case-sensitive against each node's discovered agents — a name that doesn't match anything silently resolves to zero targets, and the trigger fires as a no-op. trigger_create's response includes a warning field when a requested short name doesn't currently match any connected agent; confirm the real short name with node_list/ agent_list rather than guessing.

Trigger mutations wait for the service to confirm the database operation before returning success. trigger_create returns the created trigger's full ID as id (and its short form as id_short), while trigger_delete and trigger_toggle return only after the requested trigger is deleted or updated. Service-side validation and database errors are returned as MCP tool errors.

Traffic

  • traffic_search — Search intercepted traffic

TUI

Running praxis with no arguments launches the interactive terminal UI:

$ praxis

This page covers the interactive UI. For scripting, one-shot commands, and global options, see CLI.

Drag across text anywhere in the TUI to highlight it. Press Ctrl+C to copy the highlighted text; click elsewhere or press any other key to clear the highlight. In split views, highlighting stays within the pane where the drag started. Draggable controls such as pane dividers and chain-canvas elements retain their normal behavior.

The terminal UI provides six main windows, switched with keyboard shortcuts:

Orchestrator (Ctrl+O)

LLM-powered conversation interface for coordinating operations across the Praxis network. Features:

  • Real-time streaming responses with tool execution display
  • Plan tracking with step visualization
  • Token usage statistics
  • Command history and conversation scrolling
  • Single orchestrator session per TUI run — the conversation lifetime equals the TUI process lifetime. Use praxis --continue or praxis --resume on the next launch to bring it back. Ctrl+Alt+W exports the transcript to markdown.
  • Ctrl+C cancels the in-flight prompt
  • Ctrl+E toggles the tools panel; Ctrl+Alt+E expands it fully
  • Shift+Enter (or Alt+Enter) inserts a newline in the prompt; bare Enter sends. Multi-line drafts grow the input box; / move between lines first, then through command history.

Nodes (Ctrl+L)

Node and agent management with integrated session chat and terminal access:

  • Node list with status indicators (active/warning/inactive), OS details, and agent counts
  • Agent selection and concurrent ACP session management
  • Session Chat — direct conversation with agents, with YOLO mode and working directory selection
  • Active Sessions overlay (Ctrl+W) — see every live session across nodes and connectors; Enter to resume, Ctrl+D / Del to discard, Esc to dismiss
  • Terminal (Ctrl+Y to toggle) — full PTY terminal emulation with scrollback (see Terminal)
  • Recon (r with an agent selected in the detail pane) — view reconnaissance results directly in the terminal
  • Reset (Ctrl+R with a node selected) — cancel work, close sessions, and re-register the node after confirmation

Inside a chat view, Esc or Ctrl+W pauses the session (leaves it running on the node; resume from the Active Sessions overlay). Ctrl+C cancels an in-flight prompt, or closes the session if the agent is idle. Shift+Enter (or Alt+Enter) inserts a newline; bare Enter sends. Multi-line drafts grow the input box; / move between lines first, then through history. The status bar shows N sessions whenever any concurrent sessions are live. On first connect, whenever you open the Nodes window, and after a node reset, the TUI calls session/list on each node to pick up sessions left alive from previous runs or other clients.

Recon Overlay

The recon overlay opens as a full-screen modal from the Nodes detail pane. It is a hierarchical browser (expand/collapse groups, filter bar, detail pane) with three tabs: Config, Tools, and Sessions. See Reconnaissance for the result contents and refresh behavior.

KeyAction
Tab / 1 2 3Switch tab (Config / Tools / Sessions)
/ Move among visible tree rows
/ Collapse / expand or focus detail
Space / EnterToggle expand on branches; open leaf detail
PgUp / PgDnScroll detail pane
/Focus filter bar
rStatic recon refresh
Ctrl+USemantic recon (Discover)
Ctrl+EEdit selected Config file in $EDITOR (Config tab only)
EscUnfocus filter → clear filter → leave detail → close
Ctrl+QClose overlay

With the mouse, click chevrons to expand or collapse, click rows to select, click a selected branch again to toggle it, drag the pane split, and click the filter bar to type.

When opened, the TUI first checks the service cache for existing recon data. If none is cached, it sends an ACP _praxis/recon request to the node and polls request_recon roughly every 1.5 seconds for up to ~90 seconds. Cached recon data appears instantly on re-open.

Press Ctrl+E on a Config leaf to open the selected file in $VISUAL/$EDITOR; on a clean exit with changes, the new contents are written back to the node and the detail pane refreshes.

Intercept (Ctrl+T)

Live traffic interception with three tabs (Tab / Shift+Tab to switch). Enable/disable interception from the Nodes window with i (not from this window). The equivalent non-interactive commands are documented under CLI — Traffic Interception.

  • Traffic — incoming traffic streams from every node into a ring buffer (2,000-entry cap; bodies load lazily on selection; 64 MiB body cache). HTTP entries show individually; WebSocket frames group by flow id and HTTP/2 by flow+stream so concurrent sockets don't flood the list.
  • Rules — create, edit, delete, and toggle intercept rules (regex patterns with direction and scope). List on the left; selected-rule detail on the right (prompt, scope, match count). Rules can carry an optional LLM summarisation prompt.
  • Matches — matched-traffic review with AI summaries (when a rule has a summarisation prompt).

Traffic tab

KeyAction
Enter / Focus detail pane (then / scrolls detail)
Esc / Unfocus detail / clear filter (Esc ladder)
/Focus filter box (regex, falls back to substring)
Ctrl+EnterServer-side traffic search (while filter focused)
nCycle node filter
aCycle agent filter
pPause / resume the live stream
tToggle follow-tail
rRe-request the initial page from the service
Ctrl+XClear ALL traffic (with confirmation)
bCycle body render mode: pretty → raw → hex
yCopy selected URL
mJump to Matches for the selected traffic row

Request and response bodies arrive via a second fetch on selection to keep the broadcast payload small — large bodies load within a few hundred milliseconds after you navigate to an entry.

Rules tab

KeyAction
Focus rule detail pane (then / scrolls detail)
/ EscUnfocus detail
Ctrl+NCreate a new rule
Ctrl+EEdit the selected rule
Ctrl+DDelete the selected rule (with confirmation)
Ctrl+UDuplicate selected rule
SpaceToggle enabled / disabled
EnterJump to the Matches tab filtered to this rule
rRefresh the rules list
/Filter rules by name/pattern

The rule form (open via Ctrl+N / Ctrl+E) fields: Name, Regex (must compile — invalid patterns are rejected), Direction (send / receive / both), Scope (all / node / agent), and an optional LLM summary prompt. Tab moves between fields, Space / / cycles select-style fields, Ctrl+S saves, Esc cancels.

Matches tab

The detail pane highlights every place the rule's regex actually hit in the selected match — URL, host, method, headers, and request/response bodies (rendered per the current body mode). Every occurrence is boxed; the current one is a bright green block with white text, the rest are a dimmer green box. n / p jump between occurrences, auto-scrolling the pane, and spill over to the next / previous match row once the current one is exhausted.

KeyAction
Enter / Focus match detail pane
fCycle rule filter
n / pJump to next / previous highlighted regex-match occurrence
Esc / Unfocus detail / clear filters
rRefresh
/Filter matches
yCopy URL
bCycle body mode
Ctrl+NCreate rule from match

Log Query (Ctrl+G)

KQL-style query interface over captured logs (intercepted traffic, event logs, recon results, operations history, and more — 12 virtual tables in total). See Log Query for the full query reference.

  • Multi-line editor with basic KQL keyword highlighting
  • Ctrl+R runs the query (Ctrl+Enter is kept as an alias); the spinner in the hint line indicates in-flight execution
  • Ctrl+E opens the current query in $VISUAL / $EDITOR (falls back to vi / notepad). Save and quit cleanly to replace the in-app editor buffer with the saved text
  • Tab opens a context-aware autocomplete popup (tables at start of query, operators after |, columns inside where / project / sort, functions & keywords inline). / navigate, Enter accepts, Esc dismisses
  • ? toggles a schema sidebar listing every available table with its columns and descriptions
  • Esc from the editor moves focus to the results; i from the results moves focus back to the editor (Log Query is the exception to the list/detail focus model — it is editor-first)

Results pane:

KeyAction
PgUp PgDn g GRow navigation
EnterExpand the selected row into a key/value detail pane (JSON fields pretty-printed)
/Open a row filter (substring match across all cells)
sCycle the sort column
SToggle sort direction
rRe-run the last query
EscClose expanded row / clear filter / return to editor

Response bodies in TrafficLogs and JSON columns like ToolkitActionsLog.details_json auto-pretty-print in the detail pane.

Operations (Ctrl+P)

Operation and chain management with three tabs (Tab / Shift+Tab to switch):

  • Executions — live tracking of running/queued/completed operations and chains with duration timers
  • Library — browse operation and chain definitions with search filtering and detail view
  • Triggers — automated chain firing rules

Common actions:

  • Create new operations inline (Ctrl+N on the Library tab)
  • Create new chains via the chain builder (Ctrl+Alt+N on the Library tab, or click ^!n new chain in the hint bar)
  • Edit an existing op or chain (Ctrl+E with the row selected — opens the op form for ops, the chain builder for chains)
  • Run operations and chains with node/agent selection and YOLO mode (Ctrl+R)
  • Delete the selected op or chain (Ctrl+D)
  • In Executions, cancel the selected running operation or chain (Ctrl+C) or clear completed history (Ctrl+X, with confirmation)
  • Create, edit, enable/disable and delete chain triggers

Library tab — chain builder

The chain builder is a full-screen TUI canvas with draggable element blocks and orthogonal line connectors between ports. It is mouse-first but fully usable from the keyboard for core authoring actions.

  • Canvas — drag a block by its body to move it (drag starts only after a small movement threshold so a plain click does not nudge the block); drag empty space to pan; the mouse wheel scrolls vertically. Block positions persist in ChainDefinitionInput.positions so each chain remembers its layout.
  • Ports — every block exposes filled circles on its left (input) and right (output) edges. Ports and connector segments hit-test with a multi-cell tolerance. Click an output port and drag to an input port on another block to create a connection. A rubber-band line follows the cursor while you drag. Loop outputs are labeled r (retry / port 0) and x (exit / port 1).
  • Selection — single-click a block or connector segment to select it. Enter or double-click opens the properties modal for that selection.
  • Properties modal — for blocks: click fields to edit inline; prompts and tool params accept multi-line input (Shift+Enter inserts a newline); pickers for operation / model / tool / payload / session group; kind cycler ◂ Kind ▸ only among body kinds (Trigger and Termination stay fixed); memory mode store/retrieve; session group + per-block config (max runtime, YOLO, working dir, require-all-inputs). For connections: condition cycler toggles any / on success / on failure (also shown as ✓/✗ glyphs on the edge); c cycles condition when a connection is selected.
  • Header stripName, Category, Timeout, and Description at the top; click to edit. Incomplete blocks show a ! badge on the canvas; save rejects invalid graphs with a clear error list.
  • Palette[+ OP], [+ TXR], … buttons along the bottom. New blocks place to the right of the current selection (when one exists) and auto-wire into the graph. New chains pre-select the Trigger so the first add wires in automatically. Adding an Operation opens the op picker immediately. Keyboard (canvas only, not while the properties modal is open): o op, t transform, g generic prompt, m memory, p loop, k tool, y payload.
  • Keyboard selectionTab / Shift+Tab cycle selection through blocks then connections (no mouse required). Enter opens properties for the selection. c selects the first connection if needed and cycles its condition (any / on success / on failure).
  • LayoutLayout button or l re-runs left-to-right auto-layout from triggers.
  • Save / Cancel — top-right buttons; Ctrl+S saves; Esc closes the properties modal first, then cancels the form (confirms if there are unsaved changes). A dirty * marker appears in the hint bar while editing.

A newly created chain is seeded with a connected Trigger → Termination pair so the graph is valid out of the box; auto-layout is applied to existing chains that don't yet have stored positions.

Triggers tab

Triggers fire a chain on a schedule, when an intercept rule matches, or when a new node connects. Each trigger picks a target chain, a trigger type, and a target spec (nodes + agents, with an optional OS substring filter and, for event triggers, an "include triggering node" toggle).

KeyAction
EnterToggle enabled/disabled for the selected trigger
Ctrl+NNew trigger
Ctrl+EEdit selected trigger
Ctrl+DDelete selected trigger

In the trigger form, ↑/↓ or Tab/Shift+Tab move between fields, ←/→ cycle picker options, Space/Enter toggle checkboxes and list items, Ctrl+S saves, and Esc cancels. The form is fully mouse-driven: click a row to focus/toggle it, click Ctrl+S/Esc in the hint bar to save or cancel.

Settings (Ctrl+S)

Configuration management:

  • LLM — model definitions, provider selection, API keys, feature assignment (orchestrator, documentation helper, semantic ops, semantic parser, traffic parser), and the Traffic Parser body limit
  • Service — MCP server toggle, MCP port, Claude Bridge settings (CCRv1/CCRv2 enable and port configuration), logging, log query row limits, prompt timeout
  • About — connection info

Help Assistant (Ctrl+H)

Press Ctrl+H from any window to open the floating Help Assistant overlay — a documentation-aware chat that answers questions about Praxis without leaving the TUI. Press Ctrl+H again or Esc to dismiss.

KeyAction
Ctrl+HOpen / close the assistant
EnterSend the current question
Ctrl+TToggle inclusion of screen context (when available)
Ctrl+CStop a streaming response; close the overlay when idle
Ctrl+LClear the conversation
EscClose the assistant (cancels any in-flight response)
Up / Down / PageUp / PageDownScroll the conversation
Mouse wheelScroll the conversation

Mouse Support

The TUI supports mouse interactions across all windows:

  • Click — select items in lists, tabs, and interactive elements
  • Double-click — activate items (e.g. open an operation, select a node)
  • Drag — scroll through lists and content areas
  • Scroll wheel — scroll through lists, chat history, and scrollable content
  • Status bar — every hint in the bottom bar is a button: the six window names switch windows, ^h help opens the Help Assistant, and ^q quit exits

Mouse interactions work alongside keyboard controls in all windows and popups.

Global Keybindings

KeyAction
Ctrl+OOrchestrator window
Ctrl+LNodes window
Ctrl+POperations window
Ctrl+TIntercept window
Ctrl+GLog Query window
Ctrl+SSettings window
Ctrl+HHelp Assistant overlay (any window)
Ctrl+YToggle terminal mode (Nodes)
Ctrl+QQuit

Status bar short labels: orchestrator · nodes · ops · intercept · logs · settings · help · quit.

Ctrl+W is window-scoped: in Nodes it toggles the Active Sessions overlay (or pauses the current chat session).

Keybinding grammar

The TUI uses a consistent two-layer grammar:

LayerKeysUse
Navigate / viewbare keys, arrows, Tab, /, Enter, EscMove around lists, filter, soft toggles (pause, body mode, expand)
Change data / appalways Ctrl+Create (^n), edit (^e), delete (^d), run (^r), save (^s), clear-all (^x), windows, quit

Further conventions:

  • / opens a filter on list panes (local narrowing). Intercept Traffic also supports server search via Ctrl+Enter while the filter is focused.
  • Esc ladder: leave filter typing → clear filter → unfocus detail → close overlay.
  • List + detail: Enter / focuses detail; Esc / returns to the list (does not toggle).
  • r refreshes/reloads live data; Ctrl+R runs/executes (Operations Library, Log Query).
  • Log Query exception: i / Esc move between editor and results (editor-first window).

CLI

The Praxis CLI controls the Praxis C2 network. It has two modes in one binary: an interactive terminal UI (the default when run with no arguments) and a non-interactive command-line interface for scripting.

praxis and praxis_cli are the same binary. The crate and binary are named praxis_cli; the installers also lay down praxis as the preferred command name — a symlink on Linux/macOS, a file copy on Windows. The CLI derives its own display name from argv[0], so praxis --help and praxis_cli --help are identical apart from the name shown in the usage text. This documentation uses praxis throughout; the only places praxis_cli appears are where the artifact itself is named (installed file paths and cargo build targets).

This page covers non-interactive usage. For the interactive terminal UI, see TUI.

Purpose

The CLI is the first-party and only first-class supported client for Praxis. It provides:

  • Full-featured interactive terminal UI for hands-on control
  • Non-interactive commands for scripting and automation
  • Works equally well over SSH and in headless environments

Installation

The CLI is installed automatically with the native installation scripts:

# Linux/macOS
curl -fsSL https://praxis.originhq.com/install.sh | bash

On Linux/macOS this installs /usr/local/bin/praxis_cli and symlinks /usr/local/bin/praxis to it. Override the prefix with INSTALL_PREFIX. On Windows the CLI is installed under %USERPROFILE%\.praxis\bin\ (as both praxis_cli.exe and a praxis.exe copy) and that directory is added to the user PATH. Override the location with PRAXIS_CLI_DIR.

When using Docker, the CLI binary is built into the container image and copied to the data volume on startup. You can extract it with:

docker cp $(docker compose ps -q praxis):/app/praxis_cli ./praxis

Note: The container name depends on your project directory. Run this from the directory containing your docker-compose.yml.

Non-Interactive Mode

One-Shot Commands

Use -C to run a single command and exit:

praxis -C "node list"
praxis -C "intercept enable abc123 --method tproxy"
praxis -C "session create --node abc123 --agent codex --yolo"

Direct Subcommands

Subcommands can also be passed directly:

praxis node list
praxis intercept status
praxis session create --node abc123 --agent codex --yolo

Available Commands

Node Management:

node list                          # List all connected nodes
node select <prefix>               # Select node by ID prefix
node reset <prefix>                # Reset a node

Agent Management:

agent list --node <prefix>                   # List agents on a node
agent update --node <prefix>                 # Request agent info update
agent config read --node <prefix> --agent <name> <path>     # Read config file
agent config write --node <prefix> <path> <contents>        # Write config file (agent-independent)
agent config grep --node <prefix> --agent <name> <path> <pattern>  # Grep config file
agent session read --node <prefix> --agent <name> <file>    # Read session file
agent session grep --node <prefix> --agent <name> <file> <pattern> # Grep session file

Session Management:

session create --node <prefix> --agent <name> [--yolo] [--project <path>] [--timeout <secs>]
session prompt --node <prefix> <text>
session close --node <prefix>

Traffic Interception:

intercept status [node-prefix]                    # Show interception state
intercept enable <node-prefix> [--method proxy|vpn|hosts|tproxy]
intercept disable <node-prefix>

Enable and disable wait for the selected node to finish setup or cleanup and return the node's error if the operation fails. Independent node commands are correlated separately, so concurrent callers cannot consume one another's responses.

Only session create takes --agent explicitly, to pick which agent to start the session with; session prompt and session close operate on whichever session is currently persisted for that node and take no --agent.

Non-interactive mode persists a single session id per node in ~/.praxis/cli.jsonsession create stores it, session prompt and session close read it. The interactive TUI runs concurrent in-memory sessions and does not share state with the non-interactive subcommands.

Global Options

OptionDescriptionDefault
-t, --timeoutConnection/command timeout in seconds600
-C, --commandRun a single command and exit-
--acpRun as an ACP bridge (stdin/stdout proxy)-
--clearClear local state and exit-
--statusCheck service connection status-
--continueResume the most recent saved orchestrator session-
--resumeList saved orchestrator sessions and pick one to resume-

The RabbitMQ URL can also be set by writing PRAXIS_RABBITMQ_URL=<url> to ~/.config/praxis/config, or via the subcommands below.

Configuration

praxis set-rabbitmqurl <url>   # Persist a RabbitMQ URL to ~/.config/praxis/config
praxis config                  # Show the effective RabbitMQ URL and config file path

ACP Bridge Mode

The CLI can act as an Agent Client Protocol bridge, exposing the Praxis service as a standard ACP agent over stdin/stdout. This allows any ACP-compatible client to interact with Praxis.

praxis --acp

In this mode the CLI:

  • Reads NDJSON JSON-RPC requests from stdin
  • Forwards them to the Praxis service via RabbitMQ
  • Writes JSON-RPC responses and notifications to stdout as NDJSON
  • Only forwards responses to requests it originated (filters out other clients' traffic)

This means any ACP client can use Praxis as its agent. For example, using acpx:

acpx --agent 'praxis --acp' 'list agents'

The bridge connects with an acp_ prefixed client ID, so sessions created through it get ACP_ prefixed session IDs.

Local State

The CLI stores persistent state in ~/.praxis/cli.json. This file contains:

  • client_id: A unique identifier for this CLI instance, used for RabbitMQ queue routing
  • sessions: A map of node ID → session ID, used by session prompt and session close to resume the session session create started for that node

The client ID is generated on first run and reused for subsequent executions.

To reset local state:

praxis --clear

Agent Connectors Overview

Agent connectors are the modules that let Praxis interact with specific AI agents. Each connector knows how to fingerprint, intercept, and communicate with a particular agent type.

What Connectors Do

A connector handles four main capabilities:

Fingerprinting - Detecting whether an agent is installed, finding its executable path, and extracting its version. The helpers.find_executable Lua helper searches PATH, explicit directories, and version manager installations. Version is extracted by running --version and parsing the output.

Interception - Knowing which domains the agent talks to so traffic can be captured.

Reconnaissance - Discovering the agent's configuration, tools, and session history. This includes parsing config files, finding MCP server definitions, and locating past conversations.

Sessions - Creating interactive sessions where prompts can be sent and responses received. Different agents need different approaches-CLI agents can be spawned in a PTY, browser-based agents need DevTools or UI automation.

Current Connectors

ConnectorAgentPlatformSession ModeType
agyAntigravity CLImacOS, Linux, WindowsCLILua
claude-bridgeClaude Code (inbound)AnyCCRv1 (WS) / CCRv2 (HTTP+SSE)Native
claudecodeClaude Code CLILinux, WindowsCLILua
claudedesktopClaude DesktopWindows onlyDevTools (Electron)Lua
codexCodex CLI (OpenAI)Linux, WindowsCLILua
cursorCursor Agent CLILinux onlyCLILua
droidDroid CLI (Factory)Linux, macOS, WindowsCLILua
geminiGemini CLILinux, WindowsCLILua
m365copilotMicrosoft 365 CopilotWindows onlyDevToolsLua
piPi Coding Agent (@mariozechner/pi-coding-agent)Linux, WindowsCLILua
praxisNative LLM agent (provider-agnostic)AnyACP (native streaming)Native

Want to add support for another agent? Contributions welcome! See Adding New Connectors.

Note: Agent implementations change over time. Connectors may break when agents update and will require maintenance to work with the latest versions.

The Trait System

The connector runtime uses three Rust traits: Agent, AgentSession, and AgentRecon. Native connectors implement them directly. Lua connectors provide Lua callbacks that the shared LuaAgent and LuaAgentSession adapters expose through the same traits. See Adding New Connectors for the trait shapes and an implementation example.

Traffic interception is no longer per-agent. The set of domains and URL filters captured by the proxy is configured centrally in the praxis TUI under Settings → Intercept, and pushed to nodes by the service. Connectors do not declare intercept domains; they only need to declare a short_name which intercept targets can reference for traffic attribution.

Feature Support

Not all agents support all features. The core capabilities — fingerprinting, traffic interception, recon (config/tools/sessions discovery, with optional semantic enrichment of internal tools), and sessions — are supported by most connectors. However, some features depend on how the agent works:

Config editing requires the agent to have a file-based configuration that can be modified. CLI agents typically store settings in JSON files that can be edited directly. Browser-based agents often don't expose their configuration in an editable format.

MCP discovery only applies to agents that support the Model Context Protocol for tool extensions.

Lua-Based Connectors

In addition to compiled Rust connectors, Praxis supports writing agent connectors in Lua. Lua scripts are stored in the service database and pushed to nodes via the agent registry.

Default Scripts

Default Lua agent scripts live in the agents/ directory at the project root. These are embedded into both the node and service binaries at build time:

  • Node: Scripts from agents/ are compiled into the node binary and loaded on startup as fallback connectors.
  • Service: Scripts are embedded and seeded into the lua_agent_scripts database table on first startup. Built-in scripts are tagged with the current Praxis version.

When Praxis is upgraded to a newer version, built-in scripts are automatically updated to the latest version. User-added scripts are never modified by updates.

Built-in vs User Scripts

Scripts are tagged as either built-in or user. Built-in scripts ship with Praxis and are automatically updated when the service version changes. User scripts are created through the praxis TUI's Settings → Agents tab or uploaded manually and are never overwritten by updates.

Built-in scripts show a "builtin" badge in the script list.

Note: If you need to customize a built-in script, the recommended approach is to:

  1. Create a new script with your modifications (Settings > Agents > Upload or create new)
  2. Disable the original built-in script using the toggle in the script list
  3. Your custom script will be used instead and won't be overwritten on updates

Editing a built-in script directly is possible but not recommended, as your changes will be replaced on the next Praxis update.

Disabling Scripts

Scripts can be individually enabled or disabled via the toggle icon in the script list. Disabled scripts are not sent to nodes, so the agents they define won't be available. This is useful for:

  • Temporarily removing an agent without deleting the script
  • Replacing a built-in script with a custom version
  • Testing by toggling scripts on and off

Managing Scripts

Lua agent scripts can be managed through the Agents tab in the praxis TUI's Settings page (Ctrl+S). From there you can:

  • View and edit existing scripts
  • Upload new .lua scripts
  • Enable or disable individual scripts
  • Delete scripts
  • Reset all scripts back to the built-in defaults

When scripts are modified in the database, the service broadcasts an agent registry update to all connected nodes so they reload the latest scripts.

Adding New Connectors

Want to add support for another agent? See Adding New Connectors for a step-by-step guide.

For Rust connectors, the basic process is:

  1. Create a directory under node/src/agent_connectors/
  2. Implement the Agent trait
  3. Add fingerprinting logic
  4. Implement interception domains (if applicable)
  5. Add reconnaissance (parsing config, finding sessions)
  6. Implement session management
  7. Register in the factory

For Lua connectors, add a .lua file to the agents/ directory or upload it through the praxis TUI's Settings → Agents tab.

Connector Selection

When a node starts, it runs fingerprinting for all registered connectors. Any agent that fingerprints successfully gets added to the node's agent list and reported to the service. Agent version is also extracted and displayed in the praxis TUI.

All registered connectors are re-fingerprinted together on each cycle (roughly every 30 seconds), so both newly-installed and newly-removed agents are picked up on the next pass.

Most connectors (Claude Code, Claude Desktop, Codex, Cursor, Gemini, M365 Copilot, Pi) are Lua-based and loaded from embedded scripts or the service database. GUI-based agents like Claude Desktop (Electron) and M365 Copilot (WebView) use the praxis.cdp_* native API and praxis.devtools Lua library for Chrome DevTools Protocol interaction. The Praxis Agent and the Claude Bridge are native (Rust) connectors — the Praxis Agent is gated by service config (it appears only when enabled and a model definition is selected), and Claude Bridge is always present.

Development Builds

In debug builds, the environment variable PRAXIS_IGNORE_SERVICE_AGENTS controls whether the node uses Lua scripts pushed from the service or only its embedded scripts. It defaults to 1 (ignore service scripts) for development convenience. Set it to 0 to test service-managed scripts:

PRAXIS_IGNORE_SERVICE_AGENTS=0 cargo run --bin praxis_node

Adding New Connectors

This guide walks through creating a connector for a new AI agent.

Prefer Lua connectors for all agents. Lua scripts are easier to write, can be updated at runtime via the praxis TUI's Settings → Agents tab without recompiling, and share common helpers for executable discovery, version extraction, and multi-user support. For browser-based agents, the praxis.devtools Lua library and praxis.cdp_* native API provide Chrome DevTools Protocol support (see M365 Copilot as an example). Use Rust connectors only when you need OS-level capabilities that aren't exposed through the Lua API.

Lua agent scripts live in agents/ at the project root and are embedded into binaries at build time. They can also be uploaded via the praxis TUI's Settings → Agents tab.

Tip: Scripts uploaded or created through the TUI are tagged as user scripts and won't be overwritten by Praxis updates. If you want to customize a built-in script, create a copy with your changes and disable the original.

CLI Agents vs Browser-Based Agents

For CLI agents (e.g. Claude Code, Gemini CLI), use praxis.command_run / praxis.command_run_handle to spawn processes and interact via stdin/stdout. For agents that support the Agent Client Protocol (ACP), use the praxis.acp_* APIs for long-lived subprocess sessions with real-time streaming (see ACP Sessions below).

For browser-based agents (e.g. M365 Copilot), use the praxis.devtools library and praxis.cdp_* native API to drive the agent via Chrome DevTools Protocol. See DevTools-Based Agents below.

Script Structure

A Lua connector returns a table with name, short_name, and callback functions. For CLI agents, follow the same high-level structure used by agents/gemini.lua:

local helpers = require("praxis.helpers")

local AGENT_NAME = "Example AI"
local AGENT_SHORT_NAME = "exampleai"

local function verify_binary(path)
  local result = praxis.command_run({ program = path, args = { "--version" } })
  if result.success then
    local version = (result.stdout or ""):match("(%d[%d%.%-a-zA-Z]*)")
    return true, version
  end
  return false, nil
end

local function pick_path()
  return helpers.find_executable({
    name = "exampleai",
    global_dirs = {
      default = { "/usr/local/bin", "/usr/bin" },
    },
    home_dirs = {
      default = { "${HOME}/.local/bin" },
      windows = { "${USERPROFILE}\\.local\\bin" },
    },
    verify = verify_binary,
  })
end

return {
  name = AGENT_NAME,
  short_name = AGENT_SHORT_NAME,

  fingerprint = function(_ctx)
    local process_path, process_version = pick_path()
    return {
      available = process_path ~= nil,
      process_path = process_path,
      version = process_version,
    }
  end,

  -- Traffic interception domains are no longer declared per-agent. To
  -- capture traffic for this connector, add an entry in
  -- Settings → Intercept that references AGENT_SHORT_NAME.

  -- Optional but recommended: reconnaissance.
  -- Use run_standard_recon + declarative recon_config.
  recon = function(ctx)
    return helpers.run_standard_recon(ctx, recon_config)
  end,

  -- Required for sessions.
  create_session = function(ctx)
    return {
      handle = praxis.uuid_v4(),
      process_path = ctx.process_path,
      working_dir = ctx.working_dir,
      yolo_mode = ctx.yolo_mode == true,
    }
  end,

  session_transact = function(_ctx, state, prompt)
    local result = praxis.command_run_handle({
      program = state.process_path,
      args = { "--prompt", "-" },
      cwd = state.working_dir,
      stdin = prompt,
    }, state.handle)
    return { response = result.stdout or "", state = state }
  end,

  session_close = function(_ctx, state)
    -- Cleanup if needed.
  end,
}

Recommended pattern for recon config (same style as Gemini/Cursor/ClaudeCode):

local recon_config = {
  home_dir = ".exampleai",

  home_configs = {
    { path = ".exampleai/settings.json", type = "global_settings", mcp = true },
  },

  project_markers = { "/.exampleai/settings.json" },

  project_configs = {
    { path = ".exampleai/settings.json", type = "project_settings", mcp = true },
  },

  mcp_parsers = {
    default = helpers.parse_mcp_from_json_flexible,
  },

  auth_check = path_has_valid_auth,
  session_discovery = discover_sessions_for_home,

  session_fns = {
    create = run_create_session,
    transact = run_session_transact,
    close = run_session_close,
  },
}

Key points:

  • recon receives a context object: recon = function(ctx) ... end
  • The result must be shaped as { config = { items, project_paths }, tools = { mcp_servers, skills, internal_tools }, sessions = { items } }. helpers.run_standard_recon handles this for the standard pipeline
  • Semantic recon (driven by ctx.is_semantic) populates tools.internal_tools by interrogating the agent through config.session_fns
  • Avoid mutable global process state; return process_path from fingerprint and consume it via ctx.process_path
  • Every ACP session gets its own Lua VM loaded from compiled bytecode, so Lua globals are not shared between sessions. Keep all per-session state in the state table returned by create_session — do not stash it in module-level Lua variables expecting to read it back in session_transact.

Reading Session Content (read_session_content)

A connector can optionally define a top-level read_session_content(session_file) function. The loader checks whether it exists and, if so, calls it instead of reading session_file from disk directly — use this when a discovered session's history isn't a plain text/JSONL file. The Cursor connector (agents/cursor.lua) uses it to decode its SQLite-backed chat store: session_file arrives as a virtual "<db_path>::<session_id>" path, and the hook queries the database, walks the content-addressed blob chain, and returns the reconstructed transcript as JSONL.

read_session_content = function(session_file)
  local db_path = string.match(session_file, "^(.+)::.+$")
  if not db_path then
    return nil
  end

  -- Query the SQLite store at db_path, resolve the blob chain, and
  -- return the reconstructed transcript. See agents/cursor.lua for
  -- the full implementation.
end,

If the function is not defined, Praxis falls back to reading session_file directly from disk.

helpers.find_executable Config

The find_executable helper searches for an agent binary in 4 phases:

  1. PATH search via praxis.find_executables(name) - searches the system PATH
  2. Global directories - explicit absolute paths (e.g. /usr/local/bin)
  3. Home directories - templates expanded per user home (e.g. ${HOME}/.local/bin)
  4. Glob patterns - for version manager installations (e.g. nvm, mise)

On Windows, .cmd is tried before .exe for each directory. The verify function receives a candidate path and returns (passed, version).

Config fields:

  • name (string) - executable name for PATH search and path construction
  • global_dirs (table) - { default = {...}, windows = {...} } absolute directories
  • home_dirs (table) - same shape, directory templates with ${HOME} etc.
  • glob_paths (table) - full glob patterns (wildcards embedded in path)
  • verify (function) - fn(path) -> passed, version

OS resolution: tbl[os_name] or tbl.default or {} where os_name is "linux", "macos", or "windows".

Available Lua APIs

The praxis global provides:

  • Filesystem: path_exists, path_join, path_is_dir, path_parent, read_file, read_dir, write_file, remove_dir, walk_files, glob_files, count_file_lines, extract_user_home
  • Commands: command_run, command_run_handle, command_abort_handle
  • ACP: acp_start, acp_create_session, acp_prompt, acp_cancel, acp_is_alive, acp_close
  • Environment: os_name, user_homes, env_get, expand_path
  • Process: find_executables, kill_processes_by_name
  • CDP: cdp_spawn_and_connect, cdp_connect, cdp_evaluate, cdp_click, cdp_type_text, cdp_press_key, cdp_wait_for_element, cdp_find_elements, cdp_close, cdp_process_id
  • UIA: uia_root, uia_find_window, uia_find, uia_find_bfs, uia_find_all, uia_name, uia_classname, uia_invoke, uia_expand, uia_focus, uia_send_keys, uia_window_close, uia_toggle, uia_toggle_state, uia_bounding_rect, uia_set_foreground, uia_click_at, uia_release — see Windows UI Automation below
  • Utilities: json_decode, json_encode, toml_decode, uuid_v4, now_unix, sleep_ms, sha256_hex, hex_decode, sqlite_query, format_unix_timestamp, is_cancelled, register_cancel, unregister_cancel, semantic_discover_internal_tools, log_info, log_warn, log_debug

This list is not exhaustive — see node/src/agent_connectors/lua/runtime.rs for every registered function.

The helpers module (require("praxis.helpers")) provides find_executable, expand_path, starts_with, ends_with, dedup, parse_json, parse_toml, user_homes_with_dir, for_each_user_home_coalesce, run_standard_recon, collect_configs, extract_mcp_servers, make_verify_version_flag, has_any_env_var, subprocess_session, discover_command_skills, discover_skill_md_skills, dedup_skills, auth_via_env_or_files, resolve_working_dir, discover_jsonl_sessions, find_latest_session_file, add_config_if_exists, find_project_directories, parse_frontmatter_field, first_meaningful_line, strip_extension, and parser helpers such as parse_mcp_from_json, parse_mcp_from_json_flexible, and parse_mcp_from_toml. This list is also not exhaustive — see node/src/agent_connectors/lua/lib/helpers.lua for the complete set.

The devtools module (require("praxis.devtools")) provides connect, transact, and close for browser-based agents using Chrome DevTools Protocol, plus a renderer_ops backend for Electron apps whose renderer DOM isn't reachable from the main-process CDP connection. See DevTools-Based Agents below.

Deploying

  • Embedded: Add the .lua file to agents/ and rebuild. It will be compiled into both node and service binaries.
  • Runtime: Upload via the praxis TUI's Settings → Agents tab. The script is stored in the service database and pushed to all connected nodes.

ACP Sessions (Streaming Agents)

For agents that support the Agent Client Protocol (ACP), sessions use a long-lived subprocess with JSON-RPC 2.0 over NDJSON stdio. Praxis uses the agent-client-protocol crate internally, providing typed ClientSideConnection communication with Client trait callbacks for real-time streaming updates (text chunks, tool calls, plans, permission requests).

ACP Lua API

FunctionArgumentsReturnsDescription
praxis.acp_startspec tablehandle (string)Spawn an ACP subprocess and perform the initialize handshake
praxis.acp_create_sessionhandle, cwdsession_id (string)Create an ACP session with a working directory
praxis.acp_prompthandle, prompt, yolo, interactiveresponse (string)Send a prompt and wait for the streamed response. yolo auto-approves permission requests; interactive forwards them to the user
praxis.acp_closehandleClose the ACP session and terminate the subprocess

The acp_start spec table:

FieldTypeDescription
programstringPath to the agent executable
argstableCommand-line arguments (e.g. { "acp" } or { "--acp" })
cwdstringWorking directory for the subprocess

Example

create_session = function(ctx)
  local acp_handle = praxis.acp_start({
    program = ctx.process_path,
    args = { "--acp" },
    cwd = ctx.working_dir or "",
  })

  local session_id = praxis.acp_create_session(acp_handle, ctx.working_dir or "")

  return {
    acp_handle = acp_handle,
    acp_session_id = session_id,
    yolo_mode = ctx.yolo_mode == true,
    interactive = ctx.interactive == true,
  }
end,

session_transact = function(_ctx, state, prompt)
  local response = praxis.acp_prompt(
    state.acp_handle, prompt,
    state.yolo_mode or false,
    state.interactive or false
  )
  return { response = response, state = state }
end,

session_close = function(_ctx, state)
  if state.acp_handle then
    praxis.acp_close(state.acp_handle)
  end
end,

During acp_prompt, streaming updates (text, tool calls, tool results) are automatically forwarded to the client (the praxis TUI) in real time. The function blocks until the full response is assembled and returns the final text.


DevTools-Based Agents (Browser Automation)

For agents that run in a browser or WebView (e.g. M365 Copilot), Praxis provides a CDP (Chrome DevTools Protocol) stack. The architecture has three layers, with a choice of DOM backend at the middle layer:

your_agent.lua                    ← Agent-specific: CSS selectors, response parsing
    ↓ uses
require("praxis.devtools")        ← Generic transact loop, connect/close lifecycle
    ↓ uses one of two DOM backends
cdp_ops (direct) / renderer_ops (Electron proxy)
    ↓ uses
praxis.cdp_*                      ← Native Rust: CDP connection, JS eval, DOM ops

The devtools Module

require("praxis.devtools") provides three functions:

FunctionDescription
devtools.connect(config)Spawn a process with a debug port, connect via CDP, return handle, desktop — the CDP handle string and a hidden-desktop handle (nil if none was used)
devtools.transact(handle, adapter, prompt)Send a prompt and poll for response using the adapter's selectors
devtools.close(handle)Close the CDP connection and terminate the process tree

The connect config table:

FieldTypeDescription
process_pathstringPath to the executable
debug_port_env_varstringEnvironment variable for the debug port argument
debug_port_formatstringFormat string, e.g. "--remote-debugging-port={}"
base_portnumberBase port number (random offset added)
port_rangenumberRange for random port selection (default 778)
kill_existingboolKill existing processes first (default true)
use_hidden_desktopboolSpawn on hidden desktop on Windows (default true). In debug builds, PRAXIS_NOT_HIDDEN defaults to 1 (visible); in release builds it defaults to 0 (hidden).

DOM Operation Backends

devtools.transact(handle, adapter, prompt, ops) takes an optional 4th argument selecting which backend performs DOM operations. It defaults to devtools.cdp_ops(handle).

  • devtools.cdp_ops(handle) (default) — direct chromiumoxide Page calls. Use this when the CDP connection can already see the page DOM: WebView2 apps and direct Chrome/Chromium connections.
  • devtools.renderer_ops(handle) — tunnels DOM operations through a CDP proxy injected into an Electron main process connection. Use this when the debugger is only attached to the main process (e.g. port 9229) and the renderer's DOM isn't reachable from that connection directly. Call devtools.setup_electron_proxy(handle, url_match) once, after connecting and before the first transact, to attach webContents.debugger to the renderer webContents matching url_match and install a globalThis.cdp() bridge. Then either pass devtools.renderer_ops(handle) as the 4th argument to devtools.transact, or use the devtools.electron_transact(handle, adapter, prompt) convenience wrapper, which does both.

renderer_ops is built from lower-level functions that are rarely called directly: renderer_evaluate, renderer_wait_for_element, renderer_find_elements, renderer_click, renderer_type_text, renderer_press_key.

The Claude Desktop connector (agents/claudedesktop.lua) is the worked example: it uses UI Automation (see Windows UI Automation below) to enable Electron's main-process debugger, connects CDP to that port, then calls setup_electron_proxy and drives the UI with electron_transact.

The Adapter Table

The transact function takes an adapter table that defines how to interact with the specific agent's UI:

local my_adapter = {
  -- CSS selector for the text input element (required)
  input_selector = '#chat-input',

  -- CSS selector for response message elements (required)
  message_selector = 'div.response-message',

  -- Check response state by running JS in the page (required)
  -- Returns: { response = string|nil, is_generating = bool, has_new_messages = bool }
  check_response_state = function(handle, initial_count)
    local result = praxis.cdp_evaluate(handle, [[
      (function() {
        var messages = document.querySelectorAll('div.response-message');
        var text = '';
        if (messages.length > 0) {
          text = messages[messages.length - 1].innerText.trim();
        }
        var loading = document.querySelector('.loading-indicator');
        return {
          responseText: text,
          messageCount: messages.length,
          isGenerating: loading !== null
        };
      })()
    ]])

    local count = (result and result.messageCount) or 0
    local generating = (result and result.isGenerating) or false
    local text = (result and result.responseText) or ""

    local response = nil
    if count > initial_count and not generating and #text > 0 then
      response = text
    end

    return {
      response = response,
      is_generating = generating,
      has_new_messages = count > initial_count,
    }
  end,

  -- Optional: wait for submit button to be enabled before pressing Enter
  wait_for_submit_ready = function(handle)
    praxis.cdp_wait_for_element(handle, 'button.send:not([disabled])', 50, 100)
  end,
}

Full Example

Here is an M365-style DevTools-based agent template:

local helpers = require("praxis.helpers")
local devtools = require("praxis.devtools")

local AGENT_NAME = "My DevTools Agent"
local AGENT_SHORT_NAME = "mydevtools"

local PROCESS_NAME = "MyAgent.exe"
local INPUT_SELECTOR = '#chat-input'
local MESSAGE_SELECTOR = 'div.assistant-message'
local SEND_BUTTON_SELECTOR = 'button[aria-label=\"Send\"]:not([aria-disabled=\"true\"])'
local STOP_BUTTON_SELECTOR = 'button[aria-label=\"Stop generating\"]'

local my_adapter = {
  input_selector = INPUT_SELECTOR,
  message_selector = MESSAGE_SELECTOR,

  check_response_state = function(handle, initial_count)
    local js = "(function() {"
      .. "var msgs = document.querySelectorAll('" .. MESSAGE_SELECTOR .. "');"
      .. "var text = '';"
      .. "if (msgs.length > 0) {"
      .. "  var last = msgs[msgs.length - 1];"
      .. "  text = (last.innerText || last.textContent || '').trim();"
      .. "}"
      .. "var stopBtn = document.querySelector('" .. STOP_BUTTON_SELECTOR .. "');"
      .. "return { responseText: text, messageCount: msgs.length, isGenerating: stopBtn !== null };"
      .. "})()"
    local result = praxis.cdp_evaluate(handle, js)

    local message_count = (result and result.messageCount) or 0
    local is_generating = (result and result.isGenerating) or false
    local response_text = (result and result.responseText) or ""
    local has_new_messages = message_count > initial_count

    local response = nil
    if has_new_messages and not is_generating and #response_text > 0 then
      response = response_text
    end

    return {
      response = response,
      is_generating = is_generating,
      has_new_messages = has_new_messages,
    }
  end,

  wait_for_submit_ready = function(handle)
    praxis.cdp_wait_for_element(handle, SEND_BUTTON_SELECTOR, 100, 100)
  end,
}

local function post_initialize(handle, _working_dir)
  -- Wait for the chat UI to be ready.
  praxis.cdp_wait_for_element(handle, INPUT_SELECTOR, 30, 300)

  -- Optional: click mode toggle, open fresh chat, dismiss banners, etc.
  -- pcall(praxis.cdp_click, handle, 'button[data-testid=\"new-chat\"]')
end

local function run_create_session(ctx)
  praxis.kill_processes_by_name(PROCESS_NAME)
  praxis.sleep_ms(500)

  local cdp_handle, desktop_handle = devtools.connect({
    process_path = ctx.process_path,
    debug_port_env_var = "WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS",
    debug_port_format = "--remote-debugging-port={}",
    base_port = 9222,
    port_range = 778,
  })

  post_initialize(cdp_handle, ctx.working_dir)

  return {
    handle = cdp_handle,
    cdp_handle = cdp_handle,
    desktop_handle = desktop_handle,
    working_dir = ctx.working_dir,
    process_id = praxis.cdp_process_id(cdp_handle),
  }
end

local function run_session_transact(state, prompt)
  local response = devtools.transact(state.cdp_handle, my_adapter, prompt)
  return { response = response, state = state }
end

local function run_session_close(state)
  if state and state.cdp_handle then
    devtools.close(state.cdp_handle)
  end
  if state and state.desktop_handle then
    praxis.release_desktop(state.desktop_handle)
  end
end

local function do_recon(ctx)
  if praxis.os_name() ~= "windows" then
    return nil
  end

  local internal_tools = {}
  if ctx.is_semantic == true then
    internal_tools = helpers.discover_internal_tools(
      { process_path = ctx.process_path, working_dir = nil },
      { create = run_create_session, transact = run_session_transact, close = run_session_close }
    )
  end

  return {
    config = { items = {}, project_paths = {} },
    tools = { mcp_servers = {}, skills = {}, internal_tools = internal_tools },
    sessions = { items = {} },
  }
end

local function do_fingerprint()
  if praxis.os_name() ~= "windows" then
    return nil
  end
  local paths = praxis.find_executables(PROCESS_NAME) or {}
  if #paths > 0 then
    return paths[1]
  end
  return nil
end

return {
  name = AGENT_NAME,
  short_name = AGENT_SHORT_NAME,

  fingerprint = function(_ctx)
    local path = do_fingerprint()
    return { available = path ~= nil, process_path = path }
  end,

  recon = function(ctx)
    return do_recon(ctx)
  end,

  create_session = function(ctx)
    return run_create_session(ctx)
  end,

  session_transact = function(_ctx, state, prompt)
    return run_session_transact(state, prompt)
  end,

  session_close = function(_ctx, state)
    run_session_close(state)
  end,
}

Session State Keys

For CDP sessions to support abort and cleanup, the session state returned by create_session should include:

  • handle — used by the Rust session layer for command abort lookup
  • cdp_handle — the CDP connection handle string (cleaned up by Rust on drop)
  • desktop_handle — the hidden-desktop handle returned alongside the CDP handle by devtools.connect (nil if use_hidden_desktop was off or no hidden desktop was created). Since use_hidden_desktop defaults to true on Windows, this is normally set — release it in session_close via praxis.release_desktop(state.desktop_handle), or the hidden desktop leaks
  • process_id — the spawned process PID (killed by Rust on abort or drop)

CDP API Reference

Low-level functions available on the praxis global:

FunctionArgumentsReturnsDescription
cdp_spawn_and_connectconfig tabletable with handle and desktop fieldsSpawn process, connect via CDP. desktop is a hidden-desktop handle (nil if none was used) — see DOM Operation Backends and Session State Keys above
cdp_connectport (number)handle stringConnect to existing DevTools endpoint
cdp_evaluatehandle, js (string)valueExecute JavaScript, return result
cdp_find_elementshandle, selectorcount (number)Count matching DOM elements
cdp_clickhandle, selectorClick an element
cdp_type_texthandle, textInsert text via CDP InsertText (handles emojis)
cdp_press_keyhandle, selector, keyPress a key on an element
cdp_wait_for_elementhandle, selector, retries, delay_msboolPoll for element existence
cdp_closehandleClose connection, terminate process
cdp_process_idhandlenumber or nilGet PID of spawned process

Windows UI Automation

Some Windows agents need to drive native window chrome — menus, dialogs, toggle switches — rather than, or in addition to, a browser/WebView DOM. Praxis provides a UI Automation (UIA) stack for this, structured the same way as the DevTools stack:

your_agent.lua                     ← Agent-specific: element names, menu paths
    ↓ uses
require("praxis.uiautomation")     ← Higher-level find/invoke/toggle helpers
    ↓ uses
praxis.uia_*                       ← Native Rust: UI Automation tree walking, patterns

The Claude Desktop connector (agents/claudedesktop.lua) is the worked example. It uses UIA to open Menu → Developer → Enable Main Process Debugger and dismiss the "Inspector" popup dialogs that follow, before it ever connects CDP:

local uia = require("praxis.uiautomation")

local debugger_item = uia.open_app_menu(
  window_id,
  "Menu",
  { "Developer", "Enable Main Process Debugger" }
)

if uia.toggle_state(debugger_item) == "off" then
  uia.toggle(debugger_item)
end
uia.release(debugger_item)

UIA API Reference

Low-level functions available on the praxis global, wrapped by require("praxis.uiautomation"):

FunctionDescription
uia_rootGet the desktop root element
uia_find_windowFind a top-level window by name and/or PID
uia_findFind the first child/descendant matching criteria (name, control type, class name, or scope)
uia_find_bfsBreadth-first find — avoids the hang uia_find's Descendants scope causes on large Electron UIA trees
uia_find_allFind all matching children/descendants
uia_nameGet an element's name
uia_classnameGet an element's class name
uia_invokeInvoke an element via the UIA InvokePattern (click/activate)
uia_expandExpand an element via the UIA ExpandCollapsePattern (e.g. open a menu)
uia_focusSet keyboard focus to an element
uia_send_keysSend keystrokes to an element (focuses it first)
uia_window_closeClose a window via the UIA WindowPattern
uia_toggleToggle a checkbox/toggle element via the UIA TogglePattern
uia_toggle_stateGet an element's toggle state: "on", "off", or "indeterminate"
uia_bounding_rectGet an element's bounding rectangle ({ x, y, width, height })
uia_set_foregroundBring a window to the foreground
uia_click_atClick at screen coordinates
uia_releaseRelease an element handle to free memory

require("praxis.uiautomation") also provides higher-level helpers built on these — find_window/wait_for_window, wait_for, click_element, release_all, dismiss_dialogs, and open_app_menu. See node/src/agent_connectors/lua/lib/uiautomation.lua for the full wrapper API.


Rust Connector (for native/OS-level agents)

Use this approach only when Lua cannot access the required OS capabilities. Native connectors implement the Agent, AgentSession, and optional AgentRecon Rust traits directly. Lua scripts do not implement these traits themselves; the shared LuaAgent and LuaAgentSession adapters translate their callbacks into the same runtime interfaces.

The abbreviated trait shapes are:

#![allow(unused)]
fn main() {
#[async_trait]
trait Agent {
    fn name(&self) -> &str;
    fn short_name(&self) -> &str;
    fn as_recon(&self) -> Option<&dyn AgentRecon>;
    async fn do_fingerprint(&self) -> bool;
    fn version(&self) -> Option<String>;
    fn create_session_with_id(
        &self,
        context: &SessionContext,
        session_id: Uuid,
    ) -> Option<Arc<dyn AgentSession>>;
    fn drop_session(&self, session_id: Uuid);
}

trait AgentSession {
    fn transact(&self, prompt: &str) -> Result<String>;
    fn close(&self);
}

#[async_trait]
trait AgentRecon {
    async fn perform_recon(&self, is_semantic: bool) -> Option<ReconResult>;
}
}

Step 1: Create the Directory Structure

Create a new directory under node/src/agent_connectors/:

node/src/agent_connectors/
├── exampleai/
│   ├── mod.rs          # Main agent implementation
│   ├── fingerprint.rs  # Fingerprinting logic
│   └── session.rs      # Session management
├── factory.rs
├── mod.rs
└── traits.rs

Step 2: Implement the Agent Trait

In mod.rs:

#![allow(unused)]
fn main() {
mod fingerprint;
mod intercept;
mod recon;
mod session;

pub use session::ExampleAISession;

use crate::agent_connectors::traits::{Agent, AgentRecon, AgentSession};
use async_trait::async_trait;
use common::SessionContext;
use once_cell::sync::OnceCell;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use uuid::Uuid;

const AGENT_NAME: &str = "ExampleAI";
const AGENT_SHORTNAME: &str = "exampleai";

pub struct ExampleAIAgent {
    pub(crate) process_path: OnceCell<String>,
    //
    // Per-session state keyed by the ACP session_id handed in by
    // the node's ACP server. Nothing is shared between sessions.
    //
    sessions: Mutex<HashMap<Uuid, Arc<dyn AgentSession>>>,
}

impl ExampleAIAgent {
    pub fn new() -> Self {
        Self {
            process_path: OnceCell::new(),
            sessions: Mutex::new(HashMap::new()),
        }
    }
}

#[async_trait]
impl Agent for ExampleAIAgent {
    fn name(&self) -> &str {
        AGENT_NAME
    }

    fn short_name(&self) -> &str {
        AGENT_SHORTNAME
    }

    fn as_recon(&self) -> Option<&dyn AgentRecon> {
        Some(self)  // Return None if no recon support
    }

    async fn do_fingerprint(&self) -> bool {
        self.do_fingerprint_impl().await
    }

    fn create_session_with_id(
        &self,
        context: &SessionContext,
        session_id: Uuid,
    ) -> Option<Arc<dyn AgentSession>> {
        match ExampleAISession::new(self.process_path.get().cloned(), context, session_id) {
            Ok(session) => {
                let session_arc: Arc<dyn AgentSession> = Arc::new(session);
                self.sessions.lock().unwrap().insert(session_id, Arc::clone(&session_arc));
                Some(session_arc)
            }
            Err(e) => {
                common::log_error!("{}: Failed to create session: {}", AGENT_NAME, e);
                None
            }
        }
    }

    fn drop_session(&self, session_id: Uuid) {
        if let Some(session) = self.sessions.lock().unwrap().remove(&session_id) {
            session.close();
        }
    }
}
}

The Agent trait has two session-related hooks:

  • create_session_with_id(ctx, session_id) — called once per session/new ACP request. The node's ACP server chooses the session_id; the agent must build a session that does not share mutable state with any other session.
  • drop_session(session_id) — called on session/close (and on node reset). Release per-session resources keyed by that id.

Step 3: Implement Fingerprinting

In fingerprint.rs:

#![allow(unused)]
fn main() {
use super::ExampleAIAgent;
use std::path::PathBuf;

impl ExampleAIAgent {
    pub(crate) async fn do_fingerprint_impl(&self) -> bool {
        // Check for config file
        if let Some(config_path) = find_config_file() {
            common::log_info!("ExampleAI: Found config at {:?}", config_path);

            // Optionally find and cache the binary path
            if let Some(binary_path) = find_binary() {
                let _ = self.process_path.set(binary_path);
            }

            return true;
        }

        // Check for running process
        if is_process_running("exampleai") {
            return true;
        }

        false
    }
}

fn find_config_file() -> Option<PathBuf> {
    let home = dirs::home_dir()?;

    // Check common config locations
    let paths = [
        home.join(".exampleai/config.json"),
        home.join(".config/exampleai/config.json"),
    ];

    paths.into_iter().find(|p| p.exists())
}

fn find_binary() -> Option<String> {
    which::which("exampleai").ok().map(|p| p.to_string_lossy().to_string())
}

fn is_process_running(name: &str) -> bool {
    // Platform-specific process detection
    // ...
    false
}
}

Step 4: Configure Interception

Traffic interception is no longer declared on the connector itself. Domains and URL filters live as intercept targets in a TOML virtual file on the service, and the parsed list is pushed to nodes at runtime. To enable capture for a new connector:

  1. Open Settings → Intercept in the praxis TUI.

  2. Select Edit virtual file in $EDITOR and add a new section keyed by the connector's agent_short_name. For example:

    [exampleai]
    domains = ["api.example.ai"]
    url_pattern = "v1/chat"  # optional
    
  3. Save the file and exit the editor. The service parses the new contents, persists them, and broadcasts the updated list to all connected nodes immediately. Parse errors are reported in the settings status bar and the stored file is left untouched.

Built-in connectors ship in this file by default; use the Reset to built-in defaults action to discard local edits and start over. To disable a target without deleting it, comment out the entire section with #.

Step 5: Implement Reconnaissance

In recon.rs:

#![allow(unused)]
fn main() {
use super::ExampleAIAgent;
use crate::agent_connectors::traits::AgentRecon;
use async_trait::async_trait;
use common::ReconResult;

#[async_trait]
impl AgentRecon for ExampleAIAgent {
    async fn perform_recon(&self, is_semantic: bool) -> Option<ReconResult> {
        let mut result = ReconResult::default();

        // Discover configuration files
        if let Some(item) = discover_config() {
            result.config.items.push(item);
        }

        // Discover tools/plugins (MCP servers + skills)
        result.tools = discover_tools();

        // Discover session history
        result.sessions.items = discover_sessions();

        // Optional: populate internal_tools via semantic enrichment
        if is_semantic {
            // result.tools.internal_tools = ...;
        }

        Some(result)
    }
}

fn discover_config() -> Option<common::ConfigItem> {
    // Parse config files, return structured data
    None
}

fn discover_tools() -> common::ReconTools {
    // Find plugins, extensions, MCP servers
    common::ReconTools::default()
}

fn discover_sessions() -> Vec<common::SessionItem> {
    // Find session history files
    Vec::new()
}
}

Step 6: Implement Session Management

In session.rs:

#![allow(unused)]
fn main() {
use crate::agent_connectors::traits::AgentSession;
use anyhow::Result;
use common::SessionContext;
use uuid::Uuid;

pub struct ExampleAISession {
    session_id: Uuid,
    process_path: Option<String>,
    working_dir: Option<String>,
    pty: Option<PtyHandle>,  // Your PTY abstraction
}

impl ExampleAISession {
    pub fn new(
        process_path: Option<String>,
        context: &SessionContext,
        session_id: Uuid,
    ) -> Result<Self> {
        // Spawn the agent process
        let mut cmd = std::process::Command::new(
            process_path.as_deref().unwrap_or("exampleai")
        );

        if let Some(ref dir) = context.working_dir {
            cmd.current_dir(dir);
        }

        if context.yolo_mode {
            cmd.arg("--auto-approve");
        }

        // Create PTY and spawn
        let pty = create_pty_session(cmd)?;

        Ok(Self {
            session_id,
            process_path,
            working_dir: context.working_dir.clone(),
            pty: Some(pty),
        })
    }
}

impl AgentSession for ExampleAISession {
    fn transact(&self, prompt: &str) -> Result<String> {
        // Send prompt to PTY stdin
        // Wait for and parse response
        // Return assistant's message

        if let Some(ref pty) = self.pty {
            pty.write(prompt)?;
            let response = pty.read_until_complete()?;
            Ok(parse_response(&response))
        } else {
            Err(anyhow::anyhow!("No PTY available"))
        }
    }

    fn close(&self) {
        if let Some(ref pty) = self.pty {
            pty.close();
        }
    }
}
}

AgentSession only requires transact and close. Override these when needed:

  • acp_handle(&self) -> Option<String> — return a non-empty handle for streaming sessions so the update/permission channels can be registered against it before transact runs. Non-streaming sessions should leave the default (None).
  • abort_transaction(&self) -> bool — kill the underlying process on abort; return whether one was actually killed.
  • set_cancel_flag(&self, flag: Arc<AtomicBool>) — adopt the shared cancellation flag the handler hands in before calling transact, so session/cancel and in-loop polling share one flag.
  • transact_with_context(&self, prompt: &str, ctx: SessionTransactContext) -> Result<String> — the default implementation wires set_cancel_flag and registers acp_handle's update/permission channels, then calls transact. Override only if a session needs different wiring.

Step 7: Register in Factory

Lua connectors need no factory changes at all. Dropping the .lua file into agents/ (see Deploying above) is enough: AgentRegistry::rebuild() calls lua::load_embedded_agents() to load every embedded script and register it directly, without AgentFactory ever being involved. Claude Code, Gemini, M365 Copilot, and every other shipped connector except Praxis itself work this way.

AgentFactory::create_all_agents() only needs to know about connectors that are genuinely native Rust — currently just PraxisAgent (constructed conditionally from config) and DummyAgent (native, but only used in tests). Register a new native connector like ExampleAIAgent the same way:

Update node/src/agent_connectors/factory.rs:

#![allow(unused)]
fn main() {
use super::exampleai::ExampleAIAgent;  // Add import

impl AgentFactory {
    pub fn create_all_agents(&self) -> Vec<Arc<dyn Agent>> {
        let mut agents: Vec<Arc<dyn Agent>> = Vec::new();
        let config = self.config();

        if let Some(praxis_config) = config.praxis_agent_config {
            agents.push(Arc::new(PraxisAgent::new(praxis_config)));
        }

        // Add your new native connector.
        agents.push(Arc::new(ExampleAIAgent::new()));

        agents
    }
}
}

Update node/src/agent_connectors/mod.rs:

#![allow(unused)]
fn main() {
pub mod exampleai;  // Add this line
}

Step 8: Test

  1. Build the node: cargo build -p praxis_node
  2. Run with the target agent installed
  3. Check fingerprinting works
  4. Test reconnaissance
  5. Test session creation and prompts
  6. Test interception (if implemented)

Tips

Fingerprinting

  • Be defensive-check multiple locations
  • Handle missing files gracefully
  • Log what you find for debugging

Sessions

  • Handle terminal control sequences properly
  • Parse output carefully-agents have different formats
  • Implement proper cleanup on close

Recon

  • Start with file-based discovery (the standard pipeline in helpers.run_standard_recon)
  • Use the shared parsers (parse_mcp_from_json, parse_mcp_from_toml) when possible
  • Cache results where appropriate

Testing

  • Test without the agent installed (should not crash)
  • Test with partial configuration
  • Test session edge cases (timeouts, errors)

Antigravity (Agy) CLI Connector

The Agy connector integrates Antigravity CLI, Google's terminal AI-agent interface. It is implemented by agents/agy.lua and supports macOS, Linux, and Windows.

Fingerprinting

The connector looks for the agy executable in PATH and in the installation locations documented by Antigravity:

  • macOS and Linux: ~/.local/bin/agy
  • Windows: %LOCALAPPDATA%\agy\bin\agy.cmd or agy.exe

It verifies the executable with agy --help and reports the installed version from agy --version.

Authentication

Antigravity CLI uses the operating system's native secure keyring for its token profiles. If no active profile is available, it opens a browser-based sign-in flow locally or prints an OAuth URL when connected over SSH. Praxis therefore does not inspect a credential file during reconnaissance; run agy once to complete authentication before creating a Praxis session.

Reconnaissance

Static reconnaissance discovers the documented Agy configuration surface:

  • Global settings and keybindings under ~/.gemini/antigravity-cli/
  • Global MCP and hook definitions under ~/.gemini/config/
  • Existing workspace roots from Agy's trustedWorkspaces setting and conversation cache (stale paths are ignored)
  • Additional workspace roots found by walking each home directory (up to 7 levels deep) for .agents/mcp_config.json or .agents/hooks.json marker files
  • Workspace MCP and hook definitions under .agents/
  • Global ~/.gemini/GEMINI.md plus GEMINI.md and AGENTS.md instruction files at those workspace roots
  • Global skills from ~/.gemini/antigravity-cli/skills/ and workspace skills from .agents/skills/

Recon never recursively treats a generic GEMINI.md or AGENTS.md as a new workspace. This prevents unrelated repositories, nested instruction files, and other agents' worktrees from appearing as Agy projects.

MCP servers are read from mcp_config.json. The current Antigravity schema uses mcpServers and serverUrl for remote servers, both of which are recognized by the connector.

Sessions

Agy stores conversation transcripts at ~/.gemini/antigravity-cli/brain/<conversation-id>/.system_generated/logs/transcript.jsonl. The connector reports those transcripts as discovered sessions and uses ~/.gemini/antigravity-cli/cache/last_conversations.json to associate sessions with their workspaces.

Session Management

Sessions use Agy's documented non-interactive prompt mode:

  1. The first request runs agy -p <prompt> in the requested workspace.
  2. The connector reads the workspace's conversation ID from last_conversations.json.
  3. Later requests pass --conversation <id> to resume that exact conversation. If the cache is not yet available, it falls back to --continue.

When Praxis enables YOLO mode, the connector also passes Agy's documented --mode=accept-edits and --dangerously-skip-permissions overrides.

Files and Paths

ScopePathPurpose
Global~/.gemini/antigravity-cli/settings.jsonCLI preferences
Global~/.gemini/antigravity-cli/keybindings.jsonCustom keybindings
Global~/.gemini/antigravity-cli/cache/last_conversations.jsonConversation cache (workspace to conversation ID)
Global~/.gemini/config/mcp_config.jsonMCP servers
Global~/.gemini/config/hooks.jsonHook configuration
Global~/.gemini/GEMINI.mdGlobal instructions
Global~/.gemini/antigravity-cli/skills/Shared skills
Workspace.agents/mcp_config.jsonWorkspace MCP servers
Workspace.agents/hooks.jsonWorkspace hooks
Workspace.agents/skills/Workspace skills
WorkspaceGEMINI.md, AGENTS.mdAgent instructions
Sessions~/.gemini/antigravity-cli/brain/<id>/.system_generated/logs/transcript.jsonlConversation transcript

Claude Bridge (CCRv1 / CCRv2)

The Claude Bridge lets Claude Code connect directly to Praxis without a deployed node. Instead of Praxis spawning Claude as a child process, Claude connects inward to the service using Anthropic's Claude Code Router protocol. Each connection registers as a virtual node with an active session.

Overview

Traditional Praxis nodes discover Claude Code on the target machine, fingerprint it, and spawn it in a PTY for sessions. The Claude Bridge reverses this: the Praxis service listens on a port, and Claude Code connects to it as a remote worker. This is useful when:

  • Claude is already running (e.g. in an IDE, desktop app, or cloud environment) and you want to bring it under Praxis control
  • You want to avoid deploying a full Praxis node to the target machine
  • You are building integrations that launch Claude Code with custom environment variables

The bridge implements two protocol versions that correspond to the two transport modes Claude Code supports.

Protocol Versions

CCRv1 (WebSocket)

CCRv1 uses a bidirectional WebSocket connection with newline-delimited JSON (NDJSON). This is the simpler protocol -- Claude connects via ws:// and all messages flow over a single WebSocket.

Default port: 8586

Wire format: Each message is JSON.stringify(msg) + "\n" sent as a WebSocket text frame. Multiple JSON objects may arrive in a single frame.

Handshake:

  1. Claude opens a WebSocket connection to the bridge
  2. Bridge sends initialize control request
  3. Claude responds with control_response and system/init
  4. Bridge sends set_permission_mode (bypassPermissions)
  5. Bridge registers as a virtual node with the service

CCRv2 (HTTP + SSE)

CCRv2 uses HTTP POST for client-to-server messages and Server-Sent Events (SSE) for server-to-client messages. This is the newer protocol used by Anthropic's cloud infrastructure.

Default port: 8587

Endpoints:

EndpointMethodPurpose
/workerGETReturns worker metadata
/workerPUTWorker status updates (idle/processing)
/worker/eventsPOSTBatched messages from Claude to bridge
/worker/events/streamGETSSE stream from bridge to Claude
/worker/internal-eventsPOSTInternal events (ack with epoch check)
/worker/heartbeatPOSTKeep-alive (every ~20s from Claude)
/worker/events/deliveryPOSTEvent delivery confirmation

Epoch tracking: CCRv2 uses a worker_epoch integer that appears in every request. If a stale worker reconnects with an old epoch, the server returns 409 Conflict and Claude exits. This prevents ghost sessions from interfering with new ones.

Disconnect detection: If no activity is received for 45 seconds (heartbeats normally arrive every 20s), the bridge treats the worker as disconnected and tears down the session. SSE disconnection also triggers immediate teardown.

Enabling the Bridge

Both bridge versions are disabled by default. Enable them in the praxis TUI under Settings (Ctrl+S) > Service tab.

SettingDefaultDescription
CCRv1 EnabledfalseEnable the WebSocket (TLS) bridge listener
CCRv1 Port8586Port for WebSocket connections
CCRv2 EnabledfalseEnable the HTTPS + SSE bridge listener
CCRv2 Port8587Port for HTTPS connections

Changes take effect immediately -- the bridges restart in place when any of these settings change. TLS is always on for both bridges; there is no plaintext mode. CCRv1 only accepts wss://, CCRv2 only accepts https://. ALPN is intentionally left unset -- advertising h2/http/1.1 breaks the CCRv1 WebSocket handshake.

Per-SNI certificate issuance

The service installs a dynamic certificate resolver. For every TLS handshake it inspects the client's SNI hostname and mints a leaf certificate for that exact name on the fly, signed by a self-signed CA. The CA is generated on first start and persisted to ~/.praxis/bridge/ca_cert.pem and ~/.praxis/bridge/ca_key.pem; leaves are cached in memory only. There is no domain to configure -- whatever hostname the client requests is what gets a cert.

To make the connecting Claude Code instance trust the bridge, either:

  • point NODE_EXTRA_CA_CERTS at ~/.praxis/bridge/ca_cert.pem, or
  • launch Claude with NODE_TLS_REJECT_UNAUTHORIZED=0 to disable verification (development only).

Picking an --sdk-url hostname (Claude's allowlist)

Claude Code refuses to connect to arbitrary hostnames passed via --sdk-url. The flag is reserved for Anthropic-internal worker processes, and Claude validates the host against an allowlist of approved Anthropic endpoints. Pointing it at localhost or your own domain produces:

Error: --sdk-url rejected: host "localhost" is not an approved Anthropic endpoint.
This flag is reserved for Remote Control worker processes connecting to Anthropic's backend.

The workaround is to:

  1. Pick an approved Anthropic hostname. A staging/internal hostname that Claude accepts but that is not load-bearing for normal Claude operation works well -- e.g. one of the *.claude-ai.staging.ant.dev hosts. Avoid hostnames Claude relies on for ordinary API or login flows or you'll break the rest of the app.
  2. Redirect that hostname to your bridge, either via DNS (a private zone, split-horizon DNS, internal resolver) or by adding an entry to the local /etc/hosts (or Windows C:\Windows\System32\drivers\etc\hosts) on the machine running Claude Code that points the chosen hostname at the host running the Praxis service.
  3. Connect Claude with that hostname. The TLS handshake will send the hostname as SNI, and the bridge will mint and return a matching leaf cert automatically.

Working example (single line, with cert verification disabled for brevity):

NODE_TLS_REJECT_UNAUTHORIZED=0 claude --sdk-url wss://beacon.claude-ai.staging.ant.dev:8586

Replace beacon.claude-ai.staging.ant.dev with whichever approved host you've redirected. With NODE_EXTRA_CA_CERTS pointing at the bridge CA you can drop the NODE_TLS_REJECT_UNAUTHORIZED=0.

Connecting Claude Code

To make Claude Code connect to a Praxis bridge instead of Anthropic's servers, launch it with the appropriate environment variables and the --sdk-url flag pointing to your bridge URL, with the specified stream-json I/O formats.

CCRv1 (WebSocket)

$env:CLAUDE_CODE_SESSION_ACCESS_TOKEN = "local-token"
$env:NODE_TLS_REJECT_UNAUTHORIZED = "0"  # or set NODE_EXTRA_CA_CERTS to the bridge CA
claude --sdk-url wss://<approved-anthropic-host>:8586 --output-format stream-json --input-format stream-json

<approved-anthropic-host> must be a hostname Claude's --sdk-url allowlist accepts (see "Picking an --sdk-url hostname" above) and must resolve to your bridge via DNS or /etc/hosts. The scheme must be wss:// -- the bridge does not accept plaintext WebSocket connections. The CLAUDE_CODE_SESSION_ACCESS_TOKEN is passed as an Authorization: Bearer header on the WebSocket upgrade request. The Praxis bridge does not validate the token, so any non-empty value works. You can also omit it entirely for CCRv1 -- the WebSocket transport accepts empty auth headers.

CCRv2 (HTTP + SSE)

You must set CLAUDE_CODE_USE_CCR_V2=1 to enable the HTTPS transport. Without it, Claude Code silently does not open the HTTPS connection at all -- there is no error message, no log entry on either side, the bridge appears completely dead. If you ran claude --sdk-url https://... and praxis logs nothing whatsoever, the missing env var is almost always the cause.

$env:CLAUDE_CODE_USE_CCR_V2 = "1"               # required -- enables the SSE+POST transport
$env:CLAUDE_CODE_WORKER_EPOCH = "1"             # required -- integer epoch
$env:CLAUDE_CODE_SESSION_ACCESS_TOKEN = "local-token"  # required -- any non-empty string
$env:NODE_TLS_REJECT_UNAUTHORIZED = "0"         # or set NODE_EXTRA_CA_CERTS to the bridge CA
claude --sdk-url https://<approved-anthropic-host>:8587 --output-format stream-json --input-format stream-json

The scheme must be https:// -- the bridge does not accept plaintext HTTP. Same hostname-allowlist rules as CCRv1.

CCRv2 has stricter requirements:

VariableRequiredDescription
CLAUDE_CODE_USE_CCR_V2YesSet to "1" to select the SSE+POST transport. Without this Claude won't connect at all -- no error, no log spew, the bridge looks dead.
CLAUDE_CODE_WORKER_EPOCHYesInteger epoch (e.g. "1"). Must be present and numeric or Claude exits with missing_epoch
CLAUDE_CODE_SESSION_ACCESS_TOKENYesAuth token. Claude exits with no_auth_headers if missing. A dummy value like "local-token" works since the bridge does not validate tokens

Environment Variable Reference

VariableV1V2Description
CLAUDE_CODE_SESSION_ACCESS_TOKENoptionalrequiredBearer token for auth. V1 accepts empty headers. V2 crashes without it. A dummy value works for local bridges.
CLAUDE_CODE_USE_CCR_V2N/ArequiredWhen "1", selects SSE transport. Without it, falls back to WebSocket (V1).
CLAUDE_CODE_WORKER_EPOCHN/ArequiredInteger epoch for V2 requests. Missing or non-numeric causes missing_epoch error.
CLAUDE_CODE_ENVIRONMENT_KINDoptionaloptionalSet to "bridge" for minor diagnostic effects. Not functionally required.

Auth Token Resolution

Claude Code resolves auth tokens in this order:

  1. CLAUDE_CODE_SESSION_ACCESS_TOKEN environment variable
  2. File descriptor via CLAUDE_CODE_WEBSOCKET_AUTH_FILE_DESCRIPTOR
  3. Well-known file at CCR_SESSION_INGRESS_TOKEN_PATH (or CLAUDE_SESSION_INGRESS_TOKEN_FILE)

If all return null, V2 crashes and V1 proceeds with empty headers.

How Bridge Nodes Appear

When Claude connects, the bridge registers a virtual node with the service. This node appears in the praxis TUI just like a deployed node, with some differences:

  • Node type: claude-ccrv1 or claude-ccrv2 (shown in the TUI)
  • Machine name: Same as the node type
  • Capabilities: Session only (no interception, recon, or terminal)
  • Agent: Claude Code (auto-selected, with version reported from the system/init message)
  • Session: Automatically active in YOLO mode (bypassPermissions)
  • Working directory: Reported by Claude's system/init message (the cwd where Claude was launched)

Bridge nodes are ephemeral -- they exist only while Claude is connected. When Claude disconnects, the node is automatically deregistered and disappears from the TUI.

Using Bridge Sessions

Once connected, a bridge session works like any other Praxis session. You can:

  • Send prompts from the praxis TUI
  • Run semantic operations against the bridge node
  • Include bridge nodes in chain workflows
  • Use the orchestrator with bridge nodes

The key difference is that permissions are always bypassed (YOLO mode) -- Claude auto-approves all tool calls since the bridge sets bypassPermissions during the handshake.

One session exists per connection. Cancelling the session (session/cancel) sends an end_session control request to Claude, which terminates the process; closing the session (session/close) only tears down local bookkeeping and never messages Claude. Only one prompt can be in-flight at a time; sending a second prompt while one is active returns an error.

Troubleshooting

CCRv2 over HTTPS shows no activity in praxis logs

If you ran claude --sdk-url https://... and the praxis service logs nothing at all -- no TLS ClientHello, no connection attempt, nothing -- the cause is almost certainly a missing CLAUDE_CODE_USE_CCR_V2=1 environment variable. Without it, Claude Code does not open the HTTPS connection. There is no client-side error message either; the bridge just appears dead.

Set the full env-var trio (CLAUDE_CODE_USE_CCR_V2=1, CLAUDE_CODE_WORKER_EPOCH=1, CLAUDE_CODE_SESSION_ACCESS_TOKEN=local-token) and retry. If you still see nothing, verify your DNS / /etc/hosts redirection of the approved Anthropic hostname actually points at the bridge host (a quick curl -k https://<host>:<port>/worker from the same machine will surface this).

Claude exits immediately after connecting

CCRv2: Ensure all three required environment variables are set (CLAUDE_CODE_USE_CCR_V2, CLAUDE_CODE_WORKER_EPOCH, CLAUDE_CODE_SESSION_ACCESS_TOKEN). Missing any of them causes Claude to exit with a specific error (or, in the case of CLAUDE_CODE_USE_CCR_V2, to silently no-op the HTTPS connection).

Both versions: Check that the bridge is enabled and the port is correct. Look at the service logs for connection/handshake errors.

Node appears but no session

The bridge waits up to 30 seconds for the handshake to complete. If Claude does not respond to the initialize control request in time, the session fails. Check Claude's output for errors (API key issues, network problems, etc.).

"Prompt already in-flight" error

Bridge sessions only support one concurrent prompt. Wait for the current response before sending another. If a prompt appears stuck, cancel the transaction or close the session.

Node disappears unexpectedly

Bridge nodes are tied to the connection. If Claude crashes, the network drops, or the process is killed, the node is immediately deregistered. For CCRv2, the 45-second silence timeout also triggers cleanup if heartbeats stop.

CCRv2 epoch mismatch (409)

This means a stale worker is trying to use an old epoch. Increment CLAUDE_CODE_WORKER_EPOCH when relaunching Claude, or simply restart the bridge (toggle the setting off and on).

Claude Code Connector

The Claude Code connector enables interaction with Anthropic's Claude Code CLI agent.

Overview

Claude Code is a command-line AI assistant that can read files, execute commands, and work with code. The connector supports Linux and Windows.

Fingerprinting

The connector looks for Claude Code by checking:

  1. PATH search - Finding the claude executable in PATH
  2. Explicit paths - Checking known installation locations:
    • /usr/local/bin, /usr/bin (all platforms)
    • ~/.local/bin/claude (Linux)
    • %USERPROFILE%\.local\bin\claude.exe (Windows)
    • %APPDATA%\Claude\claude-code\<version>\claude.exe (Windows, versioned installs)

The binary is verified by running claude --version and checking the output contains "claude". If found and verified, fingerprinting succeeds and the agent appears in the node's agent list.

Interception

Traffic is intercepted for the domains:

  • api.anthropic.com
  • a-api.anthropic.com

With URL pattern filter:

  • messages - Only capture requests to the messages endpoint (filters out telemetry)

When interception is enabled, you'll see:

  • Prompts sent to the Claude API
  • Responses including assistant messages and tool calls
  • Token usage and other metadata

Authentication

Claude Code requires authentication to function. During reconnaissance, Praxis validates that valid authentication is configured before including paths in the project list.

Authentication is considered valid if any of the following are true:

  1. Environment variables - One of these is set:

    • ANTHROPIC_API_KEY
    • ANTHROPIC_AUTH_TOKEN
    • ANTHROPIC_FOUNDRY_API_KEY
    • AWS_BEARER_TOKEN_BEDROCK
  2. Preferences file - One of these fields is present in ~/.claude.json:

    • oauthAccount - OAuth login credentials
    • primaryApiKey - Direct API key
    • apiKeyHelper - External key provider
  3. Credential file - ~/.claude/.credentials.json contains a claudeAiOauth credential. This is the normal OAuth location on Linux and Windows.

  4. OAuth environment token - CLAUDE_CODE_OAUTH_TOKEN is set.

Paths without valid authentication are filtered out during reconnaissance. This prevents the UI from showing user homes or projects that cannot actually be used with Claude Code.

Reconnaissance

Static Recon

Static reconnaissance discovers:

Configuration

  • ~/.claude/settings.json (global settings) and ~/.claude.json (preferences)
  • Permission settings, model preferences, etc.

MCP Servers

  • From user and project MCP configuration (~/.claude.json, ~/.claude/mcp.json, and .mcp.json)
  • From .mcp.json files and inline mcpServers definitions in enabled Claude Code plugins
  • Server names, commands, and endpoints

Plugins

  • Active plugins are read from ~/.claude/plugins/installed_plugins.json and the scope-specific enabledPlugins setting.
  • Plugin commands and skills are discovered from the active plugin's cached installation path and shown with Claude Code's /plugin:component name.

Sessions

  • Project directories under ~/.claude/projects/
  • Session files with conversation history
  • Recent project paths
  • Working directories recorded in session transcripts populate the session working-directory picker, including projects without .claude/ files.

Semantic Recon

When semantic recon is enabled (requires Semantic Parser LLM), the connector also:

  • Parses configuration to extract tool definitions
  • Identifies internal Claude tools from session transcripts
  • Extracts capability information

Session Management

Sessions are created by spawning Claude Code as a non-interactive subprocess:

┌───────────────────────────────────────────────────────┐
│                      Praxis Node                      │
│                                                       │
│  ┌─────────────────────────────────┐                  │
│  │          CLI Session            │                  │
│  │                                 │                  │
│  │  claude ────────────────────────┼──▶ Claude Process│
│  │         │                       │                  │
│  │         └─ stdin/stdout         │                  │
│  └─────────────────────────────────┘                  │
└───────────────────────────────────────────────────────┘

Session Context

When creating a session, you can specify:

Working Directory - Where Claude should operate. This affects what files it can see with ls, cat, etc.

YOLO Mode - When enabled, passes --dangerously-skip-permissions and --add-dir (with / on Linux or C:\ on Windows) to Claude, which auto-approves all tool calls and grants access to the filesystem. Without this, Claude asks for confirmation before running commands.

Session Tracking

The connector maintains conversation context across multiple prompts:

  1. First prompt: Generates a UUID and passes --session-id <id> to Claude
  2. Subsequent prompts: Passes --resume <id> to continue the same session

This allows multi-turn conversations where Claude remembers previous context within the session.

Transacting

Sending prompts works by:

  1. Running Claude with -p flag and the prompt text
  2. Waiting for Claude to process and respond
  3. Parsing the response from stdout
  4. Returning the assistant's message

Config Editing

You can view and edit Claude's configuration files directly from the Praxis UI:

  • Main config - Model selection, permissions, API settings
  • MCP servers - Add, remove, or modify MCP server definitions

Changes are written back to disk and take effect on the next Claude session.

Tool Discovery

The connector supports both static and semantic recon. Static recon parses configuration files to discover MCP servers and settings. Semantic recon creates a session and queries the agent directly to discover internal tools and capabilities.

Files and Paths

Global (Home Directory)

FilePathContent
Global settings~/.claude/settings.jsonGlobal settings
Preferences~/.claude.jsonUser preferences
OAuth credentials~/.claude/.credentials.jsonClaude Code login credentials (Linux/Windows)
Plugin installations~/.claude/plugins/installed_plugins.jsonInstalled plugin paths and scopes
Plugin cache~/.claude/plugins/cache/Commands, skills, and plugin MCP definitions
Global instructions~/.claude/CLAUDE.mdGlobal instruction file
Projects~/.claude/projects/Session history by project

Project (Working Directory)

FilePathContent
Project settings.claude/settings.jsonProject-specific settings
Local settings.claude/settings.local.jsonLocal overrides (not committed)
Project instructionsCLAUDE.mdProject instruction file
Project MCP.mcp.jsonProject MCP server definitions

Troubleshooting

"Agent not fingerprinted"

  • Ensure Claude Code is installed and configured
  • Check that config file exists
  • Verify the claude command is in PATH

"Session creation failed"

  • Check that Claude Code can run normally from terminal
  • Verify API key is configured in Claude's settings
  • Look at node logs for detailed errors

"No MCP servers found"

  • MCP servers are optional-not all installations have them
  • Check ~/.claude/settings.json, ~/.claude.json, or project .mcp.json for your MCP server config
  • Run semantic recon for deeper tool discovery

Claude Desktop Connector

The Claude Desktop connector enables interaction with the Claude Desktop Electron app. Windows only. Experimental.

Warning: This connector is hacky and flaky. It relies on UI Automation to navigate Electron menus, a raw WebSocket CDP connection to the Node.js main process debugger, and a JavaScript proxy to tunnel CDP commands to the renderer. Any Claude Desktop update can break it. Use at your own risk.

Overview

Claude Desktop is an Electron app. Unlike browser-based agents with standard DevTools, Electron's main process debugger must be enabled manually via the app's Developer menu. The connector automates this using Windows UI Automation, then establishes a CDP connection to control the renderer.

Architecture

agents/claudedesktop.lua        <- Agent-specific: selectors, UIA flow, config
    | uses
praxis.uiautomation            <- Lua helper: BFS element search, menu navigation
praxis.devtools                 <- Lua helper: Electron proxy, transact loop
    | uses
praxis.uia_*                   <- Native Rust: Windows UI Automation bindings
praxis.cdp_*                   <- Native Rust: Raw WebSocket CDP (Node.js inspector)

How It Works

Session Creation

  1. Kill any running instance — Unconditionally kills any existing claude.exe process before spawning a fresh one
  2. Write developer_settings.json — Ensures allowDevTools: true so the Developer menu appears
  3. Launch Claude Desktop — Spawns via spawn_detached. Release builds use a hidden desktop by default so UIA automation doesn't disturb the user's visible desktop; debug builds default to visible instead (either can be overridden with PRAXIS_NOT_HIDDEN). When a hidden desktop is used, the connector switches to it before the UIA steps below
  4. Enable debugger via UI Automation — Navigates Menu > Developer > Enable Main Process Debugger using Windows UIA. Uses BFS element search to avoid hangs on Electron's large UIA tree. Retries up to 3 times
  5. Dismiss Inspector dialogs — Closes any Inspector popup windows that appear after enabling the debugger
  6. Switch back or minimize — Switches back to the original desktop if a hidden desktop was used; otherwise falls back to minimizing the window
  7. Connect to CDP on port 9229 — Uses raw WebSocket (tokio-tungstenite) instead of chromiumoxide, because Electron's main process debugger is a Node.js inspector endpoint with no pages/tabs
  8. Set up Electron renderer proxy — Injects JavaScript into the main process that uses webContents.debugger to proxy CDP commands to the renderer matching claude.ai
  9. Post-initialize — Selects Chat/Code mode, waits for input readiness, sends Ctrl+Shift+I for incognito mode

Why Not Just Use DevTools Directly?

Electron's renderer DevTools aren't exposed on a network port by default. The main process debugger (port 9229) is a Node.js inspector, not Chrome DevTools. To reach the renderer, the connector:

  1. Connects to the main process via raw WebSocket
  2. Runs Runtime.evaluate to call Electron's webContents.debugger.attach() and sendCommand() APIs
  3. Sets up a JavaScript proxy (globalThis.cdp()) that forwards CDP commands from the main process to the renderer

This is the setup_electron_proxy function in praxis.devtools.

The standard uiautomation Rust crate's find_first(Descendants) hangs for 25+ seconds on Electron's large UIA tree. The connector implements breadth-first search (uia_find_bfs) using find_first(Children) at each level, which returns instantly.

Fingerprinting

Searches for claude.exe in:

  1. PATH
  2. %LOCALAPPDATA%\AnthropicClaude

Verifies it's Claude Desktop (not Claude Code) and extracts the version via PowerShell.

Interception

Traffic is intercepted for:

  • Domains: api.anthropic.com, a-api.anthropic.com
  • URL pattern: messages

Working Directories

  • Chat (default) — Claude Desktop's chat mode
  • Code — Currently disabled (wraps Claude Code, which has a dedicated connector)

Reconnaissance

Config discovery from %APPDATA%\Claude:

  • claude_desktop_config.json — Global settings, MCP server definitions
  • config.json — App config
  • extensions-blocklist.json — Extension blocklist
  • Preferences — App preferences
  • developer_settings.json — Developer settings
  • logs/*.log — Log files

Known Issues

  • Session creation is slow (~15-20s) due to UIA menu navigation, Inspector dialog dismissal, and CDP connection handshake
  • UIA is fragile — Menu structure changes in Claude Desktop will break the debugger enablement flow
  • Response detection may not work — The CSS selectors for message elements and the stop button (div.contents, button[aria-label="Stop response"]) may not match the current Claude Desktop UI
  • Visible only as a fallback — Release builds automate on a hidden desktop by default so the user's desktop isn't disturbed; if a hidden desktop can't be obtained (or in debug builds, which default to visible), the connector falls back to a normal spawn and minimizes the window instead
  • Electron updates break things — Any change to the Electron DevTools menu structure, renderer URL, or DOM will require selector updates

Requirements

  • Windows — This connector is Windows-only
  • Claude Desktop — Must be installed (not Claude Code)
  • Hidden desktop by defaultspawn_detached is called with use_hidden_desktop = true; release builds automate on a hidden desktop (switching back afterward), debug builds default to visible, and either default can be overridden with the PRAXIS_NOT_HIDDEN environment variable

Troubleshooting

The UIA BFS search couldn't find the Menu button. Claude Desktop may have changed its UI structure, or the window didn't load in time.

"URL error: URL scheme not supported"

The CDP connection is trying to use an HTTP URL instead of a WebSocket URL. Check that the Node.js debugger on port 9229 is responding with a valid /json endpoint.

"No pages found" then falls back to raw WebSocket

This is normal. Electron's main process debugger has no pages — the raw WebSocket fallback is the expected path.

Session creation hangs

Check the node logs for which step is stuck. Common culprits:

  • UIA menu navigation (enable_debugger)
  • Inspector dialog dismissal
  • CDP connection (port 9229 not responding)

Codex CLI Connector

The Codex connector enables interaction with OpenAI's Codex CLI agent.

Overview

Codex is OpenAI's command-line coding agent that can execute commands, modify files, and work with code. The connector supports Linux and Windows.

Fingerprinting

The connector looks for Codex by checking:

  1. PATH search - Finding the codex executable in PATH

  2. Explicit paths - Checking known installation locations:

    Linux:

    • /usr/local/bin/codex
    • /usr/bin/codex
    • ~/.local/bin/codex
    • ~/.npm-global/bin/codex
    • ~/.volta/bin/codex

    Windows:

    • %LOCALAPPDATA%\Microsoft\WinGet\Links\codex.exe (WinGet)
    • %APPDATA%\npm\codex.cmd (npm global)
    • %USERPROFILE%\.volta\bin\codex.exe (Volta)
    • %USERPROFILE%\.npm-global\codex.cmd
  3. Version managers - Glob patterns for common Node.js version managers:

    • Linux: ~/.local/share/mise/installs/node/*/bin/codex, ~/.nvm/versions/node/*/bin/codex
    • Windows: %APPDATA%\nvm\*\codex.cmd

The binary is verified by running codex --version and checking the output contains "codex". If found and verified, fingerprinting succeeds and the agent appears in the node's agent list.

Interception

Traffic interception is not yet supported for this connector.

Authentication

Codex CLI requires authentication to function. During reconnaissance, Praxis validates that valid authentication is configured before including paths in the project list.

Authentication is considered valid if any of the following are true:

  1. Environment variable - OPENAI_API_KEY is set

  2. Auth file - The auth_mode field is present in ~/.codex/auth.json

Paths without valid authentication are filtered out during reconnaissance. This prevents the UI from showing user homes or projects that cannot actually be used with Codex.

Reconnaissance

Static Recon

Static reconnaissance discovers:

Configuration

  • Global config file (~/.codex/config.toml)
  • Authentication credentials (~/.codex/auth.json)
  • Project-level config (.codex/config.toml)

MCP Servers

  • From [mcp_servers.<name>] sections in config.toml
  • Server names, commands, arguments, URLs

Sessions

  • Structured session data from ~/.codex/sessions/ and ~/.codex/archived_sessions/ (per-conversation JSONL files)
  • Sessions grouped by session_id field
  • Message counts and timestamps
  • Separate, unstructured history log at ~/.codex/history.jsonl

Project Paths

  • Extracted from [projects."<path>"] sections in config.toml
  • Used for working directory selection

Semantic Recon

When semantic recon is enabled (requires Semantic Parser LLM), the connector also:

  • Creates a temporary session to query the agent
  • Discovers internal tools and capabilities
  • Extracts tool definitions from agent responses

Session Management

Sessions use the codex exec subcommand for non-interactive execution:

┌───────────────────────────────────────────────────────┐
│                      Praxis Node                      │
│                                                       │
│  ┌─────────────────────────────────────────────────┐  │
│  │               CLI Session                        │  │
│  │                                                  │  │
│  │  codex exec - ◀────── prompt via stdin          │  │
│  │            │                                     │  │
│  │            └─────────▶ Codex Process            │  │
│  └─────────────────────────────────────────────────┘  │
└───────────────────────────────────────────────────────┘

Session Context

When creating a session, you can specify:

Working Directory - Where Codex should operate. Passed via --cd <dir> option on the first prompt.

YOLO Mode - When enabled, passes --dangerously-bypass-approvals-and-sandbox and --add-dir / (Linux) or --add-dir C:\ (Windows) to Codex, which auto-approves all operations and grants full filesystem access. Without this, Codex operates with its default sandbox restrictions.

Session Tracking

The connector maintains conversation context across multiple prompts:

  1. First prompt: Runs codex exec - with configuration flags, prompt piped via stdin
  2. Subsequent prompts: Runs codex exec resume --last - to continue the session

Prompts are piped via stdin using the - argument to avoid argument parsing issues. This allows multi-turn conversations where Codex remembers previous context.

Command Line Flags

The connector uses these flags:

FlagDescription
--config history.persistence=noneDisables history persistence
--config network_access=trueEnables network access
--skip-git-repo-checkAllows running outside git repositories
--color neverDisables colored output (exec only)
--dangerously-bypass-approvals-and-sandboxYOLO mode - skips all approvals
--add-dir / or C:\YOLO mode - grants full filesystem access (exec only)
--cd <dir>Sets working directory (exec only)

Config Format

Codex uses TOML configuration files. Example ~/.codex/config.toml:

model = "o3"
model_provider = "openai"

[mcp_servers.filesystem]
command = "npx"
args = ["-y", "@anthropic/mcp-server-filesystem", "/home/user"]

[mcp_servers.github]
command = "npx"
args = ["-y", "@anthropic/mcp-server-github"]
env = { GITHUB_TOKEN = "..." }

[projects."/home/user/myproject"]
sandbox = "workspace-write"

Files and Paths

Global (Home Directory)

FilePathContent
Global settings~/.codex/config.tomlGlobal configuration
Authentication~/.codex/auth.jsonAPI credentials
Sessions~/.codex/sessions/Per-conversation JSONL files, grouped by session_id
Archived sessions~/.codex/archived_sessions/Archived per-conversation JSONL files
Session history~/.codex/history.jsonlUnstructured JSONL session log

Project (Working Directory)

FilePathContent
Project settings.codex/config.tomlProject-specific settings

Troubleshooting

"Agent not fingerprinted"

  • Ensure Codex is installed:
    • npm: npm install -g @openai/codex
    • WinGet (Windows): winget install OpenAI.Codex
  • Check that the codex command is in PATH
  • If using a version manager (mise, nvm), ensure Node.js is active

"Session creation failed"

  • Check that Codex can run normally from terminal
  • Verify API key is configured
  • Look at node logs for detailed errors
  • Try running codex exec "hello" manually to test

"stdin is not a terminal" error

  • This was fixed by using codex exec instead of interactive mode
  • Ensure you're running the latest version of the connector

Cursor Agent Connector

The Cursor connector enables interaction with Cursor's background agent CLI.

Overview

Cursor Agent is Cursor's command-line interface for AI-assisted coding. It provides similar functionality to the Cursor IDE but in a headless CLI form. The connector is Linux-only.

Fingerprinting

The connector looks for the Cursor agent CLI by checking:

  1. PATH search - Finding the cursor-agent executable in PATH
  2. Explicit paths - Checking known installation locations:
    • /usr/bin/cursor-agent
    • ~/.local/bin/cursor-agent

If found, fingerprinting succeeds and the agent appears in the node's agent list.

Interception

Traffic is intercepted for the following domains:

  • agent.api5.cursor.sh
  • api2.cursor.sh
  • cursor.sh

The proxy supports subdomain matching, so any subdomain of cursor.sh will be intercepted.

When interception is enabled, you'll see:

  • Prompts sent to the Cursor API
  • Responses including assistant messages
  • Tool calls and results

HTTP/2 and gRPC Support

Cursor uses HTTP/2 with gRPC for its streaming API (e.g., /agent.v1.AgentService/Run). The proxy fully supports HTTP/2 frame-level interception:

  • Frame types captured: HEADERS, DATA, SETTINGS, GOAWAY, etc.
  • Traffic entries: Logged as H2_HEADERS and H2_DATA methods
  • Stream tracking: Extracts :path from HPACK headers for URL context
  • Bidirectional: Both request and response frames are captured

In the praxis TUI's Intercept window, HTTP/2 traffic appears grouped by URL (similar to WebSocket), with individual frames expandable to view payloads.

Authentication

Cursor Agent requires authentication to function. During reconnaissance, Praxis validates that valid authentication is configured before including paths in the project list.

Authentication is considered valid if either of the following is true:

  1. Environment variable - CURSOR_API_KEY is set
  2. Login status - Running cursor-agent status reports "Logged in" in its output

Paths without valid authentication are filtered out during reconnaissance. This prevents the UI from showing user homes or projects that cannot actually be used with Cursor Agent.

Reconnaissance

Reconnaissance discovers:

Configuration

  • Global settings and MCP servers from .cursor/cli-config.json
  • Project settings and MCP servers from .cursor/cli.json and .cursor/mcp.json

Trusted Workspaces

  • Project paths are discovered via .workspace-trusted files under ~/.cursor/projects/<hash>/

Command Skills

  • Discovered from .cursor/commands/*.md at both user (global) and project scope

Session Management

Sessions use the Agent Client Protocol (ACP) -- a JSON-RPC 2.0 protocol over NDJSON stdio. Praxis uses the agent-client-protocol crate's ClientSideConnection for typed, async communication.

┌───────────────────────────────────────────────────────┐
│                      Praxis Node                      │
│                                                       │
│  cursor-agent acp                                     │
│         │                                             │
│         ├──▶ initialize (InitializeRequest)           │
│         ├──▶ session/new → session_id + models        │
│         ├──▶ session/prompt → streaming updates       │
│         └──▶ session/close → cleanup                  │
└───────────────────────────────────────────────────────┘

Session Context

When creating a session, you can specify:

Working Directory - Where Cursor should operate.

YOLO Mode - When enabled, tool permission requests are auto-approved.

Interactive Mode - When set (TUI sessions), permission requests are forwarded to the user for approval. Non-interactive sessions (MCP, orchestrator) auto-deny permission requests.

Session Creation

  1. cursor-agent acp is spawned as an async subprocess via tokio::process::Command
  2. ClientSideConnection established over stdin/stdout
  3. InitializeRequest handshake establishes the connection and negotiates capabilities
  4. NewSessionRequest creates a session with the working directory

Transacting

Sending prompts uses typed ACP requests:

  1. A PromptRequest is sent with the prompt text as ContentBlock::Text
  2. The agent streams back real-time SessionUpdate notifications: AgentMessageChunk, ToolCall, ToolCallUpdate, Plan, and UsageUpdate
  3. Permission requests arrive via the Client trait's request_permission callback
  4. The prompt completes with a PromptResponse containing a StopReason

Cancellation

Sessions support mid-prompt cancellation:

  1. A CancelNotification is sent to the agent
  2. The agent responds to the original PromptRequest with StopReason::Cancelled
  3. Any partial output is preserved in the conversation

Session Cleanup

When a session is closed, Praxis sends CloseSessionRequest via ACP, then terminates the subprocess.

Files and Paths

Session History

LocationPathContent
Chat history~/.config/cursor/chats/<project_hash>/<chat_id>/Session files

Binary Locations

PlatformPaths Checked
Linux/usr/bin/cursor-agent, ~/.local/bin/cursor-agent, PATH

Troubleshooting

"Agent not fingerprinted"

  • Ensure cursor-agent is installed
  • Verify the command is in PATH or at a known location
  • Check file permissions

"Session creation failed"

  • Verify cursor-agent acp starts and completes the ACP handshake from terminal
  • Check that Cursor is authenticated
  • Look at node logs for detailed errors

"Traffic not appearing"

  • Ensure interception is enabled
  • Check that the proxy is using VPN or TPROXY mode (not system proxy)
  • Verify HTTP/2 traffic is being captured (check for H2_DATA entries)

"HTTP/2 connection issues"

  • The proxy handles HTTP/2 frame-level interception automatically
  • If traffic appears but the agent fails, check for certificate trust issues
  • gRPC streaming is supported - both directions are captured

Droid CLI Connector

The Droid connector enables interaction with Droid, Factory's command-line coding agent (binary name droid, config directory .factory).

Overview

Droid CLI is Factory's terminal coding agent. Praxis drives it by spawning droid exec as a one-shot subprocess per prompt and re-attaching to the on-disk session file Droid itself writes, in order to resume multi-turn conversations - there is no long-lived PTY or ACP connection for this connector. The connector has no OS gate of its own, so fingerprinting, recon, and sessions all run on whichever OS the node is on; binary discovery covers Linux/macOS (shared default paths) and Windows (explicit overrides).

Fingerprinting

The connector looks for droid by checking, in order:

  1. PATH search - Finding the droid executable in PATH
  2. Explicit global directories - /usr/local/bin, /usr/bin (no Windows-specific override is defined, so these two Unix paths are also probed on Windows, where they never resolve to anything)
  3. Home-relative directories, expanded per discovered user home:
    • Linux/macOS: ~/.local/bin/droid
    • Windows: %USERPROFILE%\.local\bin\droid.cmd / droid.exe

Unlike the Pi, Gemini, or Codex connectors, Droid does not define any version-manager glob patterns - there is no fallback search through nvm/mise/volta/bun install directories. If droid isn't on PATH, in the two global directories, or in ~/.local/bin (or the Windows equivalent), fingerprinting fails.

The candidate binary is verified by running droid --version (10s timeout). If verification passes, fingerprinting succeeds and the agent appears in the node's agent list.

Interception

Traffic is intercepted for the domains:

  • api.factory.ai
  • staging.api.factory.ai
  • preprod.api.factory.ai
  • dev.api.factory.ai

Authentication

During reconnaissance, Praxis validates that authentication is configured before including a path in the project list. Authentication is considered valid if either of the following is true:

  1. Environment variable - FACTORY_API_KEY is set
  2. Co-located credential files - .factory/auth.v2.file exists under the candidate home/project and the sibling .factory/auth.v2.key also exists next to it. Droid stores its encrypted credential bundle as this file pair; the connector only checks that both files are present on disk, not their contents.

Paths without valid authentication are filtered out of the recon project list.

Reconnaissance

Static Recon

Configuration

  • Global settings (~/.factory/settings.json)
  • Global local-settings override (~/.factory/settings.local.json)
  • Global MCP config (~/.factory/mcp.json) - parsed for MCP servers
  • Project settings (.factory/settings.json)
  • Project local-settings override (.factory/settings.local.json)
  • Project MCP config (.factory/mcp.json) - parsed for MCP servers
  • Project instructions (AGENTS.md)

Project discovery

A directory is treated as a Droid project if a recursive walk finds any of:

  • .factory/settings.json
  • .factory/mcp.json
  • AGENTS.md

A bare AGENTS.md - with no .factory/ directory at all - is enough to mark a project root; the connector piggybacks on the cross-agent AGENTS.md convention rather than requiring Factory-specific config to be present.

Custom commands (skills)

Discovers Markdown custom commands under .factory/commands/*.md, at both user scope (~/.factory/commands) and per-project scope (<project>/.factory/commands). Each file becomes one skill named /<relative-path-without-extension> (nested folders form namespaced names, e.g. /git/commit). The description comes from the file's frontmatter description: field, falling back to the first non-blank line of the file.

Sessions

  • Session JSONL files under ~/.factory/sessions/<encoded-cwd>/*.jsonl
  • Session ID is the filename with the .jsonl extension stripped (the full stem, not a trailing UUID segment)
  • Message counts (line counts) and last-modified timestamps come from the shared JSONL walker

Semantic Recon

When semantic recon is enabled (requires the Semantic Parser LLM), the connector creates a temporary session and queries the agent to discover internal tools/capabilities, the same two-prompt fallback all standard-recon connectors share.

MCP

Droid supports MCP. Server definitions are read from the mcpServers key of .factory/mcp.json (home and project scope): command/args pairs become Stdio servers, url/httpUrl become SSE servers.

Session Management

Sessions run droid exec as a one-shot subprocess per prompt. The prompt is passed as a positional argument, not piped via stdin:

┌──────────────────────────────────────────────────────┐
│                     Praxis Node                      │
│                                                       │
│  ┌──────────────────────────────────────────────┐    │
│  │              CLI Session                      │    │
│  │                                                │    │
│  │  droid exec [-s <id>] "<prompt>"               │   │
│  │     │                                          │   │
│  │     └─────────▶ Droid Process (exits after reply) │
│  └──────────────────────────────────────────────┘    │
└──────────────────────────────────────────────────────┘

Session Context

Working Directory - Uses ctx.working_dir if provided, otherwise the first discovered user home containing a .factory directory. Passed as the spawned process's cwd.

YOLO Mode - When enabled, appends --skip-permissions-unsafe to the invocation - Droid's own flag name for bypassing its permission/approval prompts. There is no separate "interactive" permission-forwarding mode for this connector (unlike the ACP-based connectors) - yolo_mode on or off is the only toggle.

Session Tracking

The connector maintains conversation context across multiple prompts by discovering Droid's own on-disk session file after each call, rather than holding a persistent process open:

  1. First prompt: Runs droid exec <prompt> (plus --skip-permissions-unsafe if YOLO mode is on). No -s flag yet, since no session id is known.
  2. Session discovery: After the call returns, if no session id is pinned yet, the connector looks in <home>/.factory/sessions/<encoded-cwd>/ for the most recently modified .jsonl file and takes its filename (minus .jsonl) as the session id.
  3. Subsequent prompts: Runs droid exec -s <session-id> <prompt> to resume that same session.

Command Line Flags

FlagDescription
execNon-interactive execution subcommand - always the first argument
--skip-permissions-unsafeYOLO mode - bypasses Droid's permission/approval prompts
-s <session-id>Resume a specific session (subsequent prompts only)

Other Droid flags are not set by the connector.

Cancellation

Each invocation runs through the node's subprocess handle tracking, keyed to a per-session handle generated once when the session is created. The node tracks the spawned process's PID against that handle; cancelling the session kills the whole process tree, so an in-flight droid exec call can be terminated immediately, not merely noticed at a poll boundary.

Timeouts

Each droid exec call uses the session's configured prompt timeout if supplied, otherwise a default of 1800 seconds (30 minutes).

Session Close

session_close is a no-op for this connector - there is nothing to tear down between turns since each turn is already a short-lived subprocess (contrast with Gemini, which calls --delete-session on close).

Session Storage

Droid stores sessions per working directory, similar to Pi, but with a simpler encoding: forward slashes in the working directory path are replaced with dashes, with no leading-separator strip, no backslash handling, and no wrapping (contrast with the Pi connector's encoder, which handles both separator types, strips a leading one, and wraps the result). On a Windows working directory such as C:\Users\foo\bar (no forward slashes), this encoding leaves the string unchanged before it's joined onto <home>/.factory/sessions/.

Session files inside that directory are named <something>.jsonl; the connector treats the full filename (minus .jsonl) as the canonical session id - it does not parse out a trailing UUID segment the way the Pi connector does. This is consistent between session creation (resume) and recon (session listing), since both use the same filename-stripping logic.

Files and Paths

Global (Home Directory)

FilePathContent
Global settings~/.factory/settings.jsonMain configuration
Global local settings~/.factory/settings.local.jsonLocal override of global settings
Global MCP config~/.factory/mcp.jsonMCP server definitions
Custom commands~/.factory/commands/*.mdUser-scope slash commands
Auth credential file~/.factory/auth.v2.fileEncrypted credential bundle (paired with .key)
Auth credential key~/.factory/auth.v2.keyCompanion key file for the credential bundle
Sessions~/.factory/sessions/<encoded-cwd>/<id>.jsonlPer-cwd JSONL conversation logs

Project (Working Directory)

FilePathContent
Project settings.factory/settings.jsonProject-specific overrides
Project local settings.factory/settings.local.jsonLocal override of project settings
Project MCP config.factory/mcp.jsonProject-scoped MCP server definitions
Project instructionsAGENTS.mdCross-agent project instructions file (also a project marker)
Custom commands.factory/commands/*.mdProject-scope slash commands

Troubleshooting

"Agent not fingerprinted"

  • Ensure droid is installed and the binary is literally named droid (droid.exe/droid.cmd on Windows)
  • Check that droid is on PATH, or installed under ~/.local/bin (%USERPROFILE%\.local\bin on Windows) - no other install location or version manager is searched
  • Run droid --version manually and confirm it prints something version-like

"Session creation failed"

  • Check that droid can run normally from a terminal
  • Verify FACTORY_API_KEY is set, or that both .factory/auth.v2.file and .factory/auth.v2.key exist under the home/project being used
  • Try droid exec "hello" manually to confirm non-interactive mode works
  • Look at node logs for detailed errors

"Subsequent prompts started a new conversation"

  • The connector only discovers the session id after the first call returns, by scanning .factory/sessions/<encoded-cwd>/ for the newest .jsonl. If that directory doesn't contain the expected file - e.g. a concurrent droid process wrote a newer one, or the encoded directory name doesn't match (see Session Storage, especially on Windows) - the next call won't find the right id and Droid starts a fresh conversation.

MCP servers not showing up

  • Confirm .factory/mcp.json contains an mcpServers object (not top-level server entries) - the connector only parses that shape

Limitations

  • No version-manager (nvm/mise/volta/bun) discovery - only PATH, /usr/local/bin, /usr/bin, and ~/.local/bin (or the Windows equivalent) are checked.
  • No "interactive" permission-forwarding mode - only the blunt yolo_mode on/off toggle (--skip-permissions-unsafe).
  • No config editing beyond the config files listed above.
  • session_close performs no cleanup (there is no persistent process or remote session to tear down).
  • Session-directory encoding only replaces /; Windows working directories containing \ are not normalized the same way Pi's connector normalizes them (see Session Storage).

Gemini CLI Connector

The Gemini connector enables interaction with Google's Gemini CLI agent. It is implemented as a Lua agent script (agents/gemini.lua).

Overview

Gemini CLI is Google's command-line AI assistant. Like Claude Code, it can read files, execute commands, and work with code. The connector supports Linux and Windows.

Fingerprinting

The connector looks for Gemini CLI by checking:

  1. PATH search - Finding the gemini executable in PATH (prefers .cmd on Windows)
  2. Explicit paths - Checking known installation locations:
    • Linux: ~/.local/bin/gemini, /usr/local/bin/gemini, /usr/bin/gemini
    • Windows: %USERPROFILE%\.local\bin\gemini.cmd, %USERPROFILE%\AppData\Roaming\npm\gemini.cmd, etc.

If found, fingerprinting succeeds and the agent appears in the node's agent list.

Interception

Traffic is intercepted for the domains:

  • generativelanguage.googleapis.com
  • cloudcode-pa.googleapis.com

When interception is enabled, you'll see:

  • Prompts sent to the Gemini API
  • Responses including assistant messages
  • Function/tool calls and results

Authentication

Gemini CLI requires authentication to function. During reconnaissance, Praxis validates that valid authentication is configured before including paths in the project list.

Authentication is considered valid if any of the following are true:

  1. Environment variables - One of these is set:

    • GEMINI_API_KEY
    • GOOGLE_API_KEY
    • GOOGLE_GENAI_USE_VERTEXAI
    • GOOGLE_GENAI_USE_GCA
  2. Settings file - The security.auth object is present in the relevant settings.json:

    • For user homes: ~/.gemini/settings.json
    • For project paths: .gemini/settings.json in the project, or the owning user's home settings

Paths without valid authentication are filtered out during reconnaissance. This prevents the UI from showing user homes or projects that cannot actually be used with Gemini.

Reconnaissance

Static Recon

Static reconnaissance discovers:

Configuration

  • User settings (~/.gemini/settings.json)
  • Google account info (~/.gemini/google_accounts.json)
  • OAuth credentials (~/.gemini/oauth_creds.json)
  • System defaults and settings (platform-specific paths)
  • Environment variable snapshot - when set, ~13 relevant variables (GEMINI_API_KEY, GEMINI_MODEL, GOOGLE_CLOUD_PROJECT, GOOGLE_APPLICATION_CREDENTIALS, etc.) are captured into a synthetic config item

Context Files

  • Global context (~/.gemini/GEMINI.md)
  • Project context files (configurable via context.fileName in settings)

Custom Commands (Skills)

  • Custom commands under .gemini/commands/**/*.toml, discovered at both user scope (~/.gemini/commands) and project scope
  • Nested directories form namespaced command names (e.g. git/commit.toml becomes /git/commit)

Sessions

  • Session files under ~/.gemini/tmp/<project_hash>/chats/
  • Session metadata including message count and timestamps

Semantic Recon

When semantic recon is enabled, the connector also creates a session and queries the agent directly to discover internal tools and capabilities.

Session Management

Sessions use the Agent Client Protocol (ACP) -- a JSON-RPC 2.0 protocol over NDJSON stdio. Praxis uses the agent-client-protocol crate's ClientSideConnection for typed, async communication.

Session Context

When creating a session, you can specify:

Working Directory - Where Gemini should operate.

YOLO Mode - When enabled, tool permission requests are auto-approved.

Interactive Mode - When set (TUI sessions), permission requests are forwarded to the user for approval. Non-interactive sessions (MCP, orchestrator) auto-deny permission requests.

Transacting

  1. gemini --acp is spawned as an async subprocess
  2. ClientSideConnection established, InitializeRequest handshake performed
  3. PromptRequest sends the prompt; the agent streams back SessionUpdate notifications (text chunks, tool calls, plans, tool results)
  4. Permission requests handled via the Client trait callback
  5. PromptResponse returned with StopReason on completion

Cancellation

Sessions support mid-prompt cancellation via CancelNotification. The agent responds with StopReason::Cancelled and any partial output is preserved.

Config Editing

You can view and edit Gemini's configuration files directly from the Praxis UI:

  • User settings with model and API preferences
  • Context files

Changes are written back to disk and take effect on the next Gemini session.

Tool Discovery

The connector supports both static and semantic recon. Static recon parses configuration files to discover settings and context files. Semantic recon creates a session and queries the agent directly to discover internal tools and capabilities.

Files and Paths

Global (Home Directory)

FilePathContent
User settings~/.gemini/settings.jsonMain configuration
Google accounts~/.gemini/google_accounts.jsonAccount info
OAuth credentials~/.gemini/oauth_creds.jsonAuth credentials
Global context~/.gemini/GEMINI.mdGlobal instruction file
Sessions~/.gemini/tmp/<hash>/chats/Session history by project

System (Platform-specific)

FileLinux PathWindows Path
System defaults/etc/gemini-cli/system-defaults.jsonC:\ProgramData\gemini-cli\system-defaults.json
System settings/etc/gemini-cli/settings.jsonC:\ProgramData\gemini-cli\settings.json

Project (Working Directory)

FilePathContent
Project settings.gemini/settings.jsonProject-specific settings
Project context.gemini/GEMINI.mdProject instruction file (configurable)

Troubleshooting

"Agent not fingerprinted"

  • Ensure Gemini CLI is installed
  • Verify the gemini command is in PATH
  • On Windows, check that the .cmd wrapper exists

"Session creation failed"

  • Check that Gemini CLI can run normally from terminal
  • Verify Google API credentials are configured
  • Look at node logs for detailed errors

M365 Copilot Connector

The M365 Copilot connector enables interaction with Microsoft 365 Copilot. Windows only.

Overview

Microsoft 365 Copilot runs in a WebView2 browser component. The connector uses Chrome DevTools Protocol (CDP) via the praxis.devtools Lua library to interact with the Copilot UI programmatically.

Architecture

agents/m365copilot.lua        ← Agent-specific: selectors, recon JS, config
    ↓ uses
praxis.devtools               ← Lua helper: generic transact loop, lifecycle
    ↓ uses
praxis.cdp_*                  ← Native Rust: CDP connection, JS eval, DOM ops

The M365 connector is a Lua agent (agents/m365copilot.lua) that uses the shared praxis.devtools library for DevTools session management and the native praxis.cdp_* API for CDP operations.

Fingerprinting

The connector checks for Copilot availability:

  1. Searches PATH for M365Copilot.exe
  2. Checks the Windows package install location (Microsoft.MicrosoftOfficeHub)

Interception

Traffic is intercepted for:

  • Domain: substrate.office.com
  • URL pattern: m365Copilot/Chathub

Session Management

Creating Sessions

When you create a session:

  1. All running M365Copilot.exe processes are killed by name
  2. All existing CDP connections are drained and their process trees terminated
  3. App is launched with a random debugging port via WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS
  4. On Windows, the process is spawned on a hidden desktop so the window is invisible (release builds by default; debug builds default to visible). Override with PRAXIS_NOT_HIDDEN=1 to show the window, or PRAXIS_NOT_HIDDEN=0 to hide it in debug builds. Independent of the hidden-desktop outcome, the window is also minimized after DevTools connects, unconditionally whenever a process id is available.
  5. CDP connection is established via chromiumoxide (5 attempts, 2s interval)
  6. Post-initialization: waits for input element, clicks Work/Web toggle, opens new private chat

Transacting

The praxis.devtools library provides a generic transact loop:

  1. Waits for input element (#m365-chat-editor-target-element)
  2. Counts existing messages
  3. Clicks input, inserts text via CDP InsertText (handles emojis/special chars), presses Enter
  4. Polls for response (250ms interval, 120s max)
  5. Detects idle state (no activity for ~3s) and retries up to 3 times

Response completion is detected by checking:

  • New div[data-testid="markdown-reply"] elements
  • Absence of "Stop generating" button
  • Non-empty response text

Aborting

CDP sessions support abort_transaction — when a transaction is cancelled (e.g. via the praxis TUI), the entire process tree is terminated by PID. The session state stores the process_id which the Rust session layer uses for process-level cancellation.

Cleanup Safety Net

When a session is closed (or dropped), the Rust layer performs cleanup even if the Lua session_close callback fails:

  • Kills the process tree by PID
  • Removes the CDP connection handle from the global map

This prevents orphaned browser processes after crashes or Lua errors.

Working Directories

M365 Copilot supports two working directories that map to toggle buttons:

  • Work - Enterprise/organizational context
  • Web - Web search context

Reconnaissance

Static Recon

Discovers available Work/Web toggles by checking for toggle button elements, via JavaScript executed in a temporary DevTools session.

Recon requires a valid process_path from a prior fingerprint. If fingerprint hasn't run, recon returns empty results.

Semantic Recon

Not implemented for this connector. Recon always returns an empty tools result, regardless of whether semantic recon is enabled.

Requirements

  • Windows - This connector is Windows-only
  • M365 License - User must have Copilot access
  • Logged In - User must be authenticated to Microsoft

Troubleshooting

"Agent not fingerprinted"

  • Verify the user has M365 Copilot access
  • Check that M365Copilot.exe is installed

"Session creation failed"

  • Check that the app can launch with debugging enabled
  • Verify M365 authentication is valid
  • Look for firewall blocking debugging ports (9222-9999 range)
  • Check node logs for CDP errors
  • Set PRAXIS_NOT_HIDDEN=1 to see the app window for debugging

"Responses not captured"

  • UI selectors may have changed; report as an issue
  • Check for Copilot page structure changes

Limitations

  • No config editing (browser-based)
  • No MCP server discovery
  • Requires active M365 authentication
  • Session reliability depends on Microsoft's UI

Pi Coding Agent Connector

The Pi connector enables interaction with Pi (@mariozechner/pi-coding-agent) — a minimal terminal coding harness from the pi-mono toolkit.

Overview

Pi is an open-source CLI coding agent that drives a model with four built-in tools (read, write, edit, bash) and is extensible via TypeScript extensions, skills, prompt templates, and themes. The connector supports Linux and Windows.

Fingerprinting

The connector looks for Pi by checking:

  1. PATH search - Finding the pi executable in PATH

  2. Explicit paths - Checking known installation locations:

    Linux:

    • /usr/local/bin/pi
    • /usr/bin/pi
    • ~/.local/bin/pi
    • ~/.npm-global/bin/pi
    • ~/.volta/bin/pi
    • ~/.bun/bin/pi

    Windows:

    • %LOCALAPPDATA%\Microsoft\WinGet\Links\pi.exe
    • %APPDATA%\npm\pi.cmd (npm global)
    • %USERPROFILE%\.volta\bin\pi.exe (Volta)
    • %USERPROFILE%\.npm-global\pi.cmd
    • %USERPROFILE%\.bun\bin\pi.exe (Bun)
  3. Version managers - Glob patterns for common Node.js version managers:

    • Linux: ~/.local/share/mise/installs/node/*/bin/pi, ~/.nvm/versions/node/*/bin/pi
    • Windows: %APPDATA%\nvm\*\pi.cmd

The binary is verified by running pi --version and matching the output against a semver pattern (e.g. 0.70.6). If found and verified, fingerprinting succeeds and the agent appears in the node's agent list.

Interception

Traffic interception is not configured for this connector. Pi forwards traffic to whichever provider it is configured to use (Anthropic, OpenAI, Google, OpenRouter, Fireworks, etc.), so interception domains depend on user configuration rather than a fixed agent endpoint.

Authentication

Pi supports multiple providers and stores credentials via its internal AuthStorage. During reconnaissance, Praxis validates that authentication is configured before including paths in the project list.

Authentication is considered valid if any of the following are true:

  1. Environment variable - ANTHROPIC_API_KEY is set
  2. Auth file - ~/.pi/agent/auth.json exists in the user's home

Paths without valid authentication are filtered out during reconnaissance.

Reconnaissance

Static Recon

Static reconnaissance discovers:

Configuration

  • Global settings (~/.pi/agent/settings.json)
  • Authentication credentials (~/.pi/agent/auth.json)
  • Per-provider model preferences (~/.pi/agent/models.json)
  • Project-level settings (.pi/settings.json)

Sessions

  • Session JSONL files under ~/.pi/agent/sessions/<encoded-cwd>/
  • Session ID extracted from the trailing UUID segment of each filename
  • Message counts (line counts of the JSONL) and last-modified timestamps
  • The subagent-artifacts subdirectory is skipped

Project Paths

  • Discovered via the /.pi/settings.json project marker

Semantic Recon

When semantic recon is enabled (requires Semantic Parser LLM), the connector also:

  • Creates a temporary session to query the agent
  • Discovers internal tools and capabilities
  • Extracts metadata from collected configuration files

MCP

Pi does not support MCP. Per its README ("No MCP. Build CLI tools with READMEs (see Skills), or build an extension"), the connector emits no MCP entries during recon. Tools are surfaced through Pi's native extensions and skills system instead.

Session Management

Sessions use the pi -p non-interactive (print) mode with the prompt piped via stdin:

┌───────────────────────────────────────────────────────┐
│                      Praxis Node                      │
│                                                       │
│  ┌─────────────────────────────────────────────────┐  │
│  │               CLI Session                        │  │
│  │                                                  │  │
│  │  pi -p ◀────── prompt via stdin                 │  │
│  │     │                                            │  │
│  │     └─────────▶ Pi Process                      │  │
│  └─────────────────────────────────────────────────┘  │
└───────────────────────────────────────────────────────┘

Session Context

When creating a session, you can specify:

Working Directory - Where Pi should operate. Passed via the spawned process's cwd. Pi uses this directory both as the initial workspace and to derive the per-cwd sessions directory.

YOLO Mode - Pi has no permission gate of its own — it executes tool calls without prompting. The yolo_mode flag is therefore a no-op for this connector. The Pi maintainers recommend running it inside a container or extension for confined execution.

Session Tracking

The connector maintains conversation context across multiple prompts:

  1. First prompt: Runs pi -p with the prompt piped via stdin. Pi creates a new session JSONL file under ~/.pi/agent/sessions/<encoded-cwd>/.
  2. Session discovery: After the first call, the connector locates the most recently modified .jsonl file in that directory and pins it to the session state.
  3. Subsequent prompts: Runs pi -p --session <path> to continue the same conversation deterministically. This is preferred over --continue, which can race with other Pi processes running in the same cwd.

Command Line Flags

The connector uses these flags:

FlagDescription
-pNon-interactive (print) mode — process prompt and exit
--session <path>Pin the conversation to a specific session file (subsequent prompts only)

Other Pi flags (--provider, --model, --thinking, --no-tools, etc.) are not set by the connector — Pi uses the user's defaultProvider/defaultModel from ~/.pi/agent/settings.json.

Session Storage

Pi stores sessions per working directory. The session directory name is derived from the cwd by:

  1. Stripping any leading / or \
  2. Replacing each /, \, and : with -
  3. Wrapping the result with -- on both sides

Examples:

Working directorySession directory name
/home/user/code/proj--home-user-code-proj--
C:\Users\foo\bar--C--Users-foo-bar--

Session files inside that directory are named <iso-timestamp>_<uuid>.jsonl. The trailing UUID segment is the canonical session id and matches the id field on the first JSONL line.

Files and Paths

Global (Home Directory)

FilePathContent
Global settings~/.pi/agent/settings.jsonDefault provider, model, thinking level, theme, installed packages
Authentication~/.pi/agent/auth.jsonPer-provider credentials managed by AuthStorage
Model preferences~/.pi/agent/models.jsonPer-provider model selection cache
Sessions~/.pi/agent/sessions/<encoded-cwd>/<id>.jsonlPer-cwd JSONL conversation logs

Project (Working Directory)

FilePathContent
Project settings.pi/settings.jsonProject-specific overrides

Troubleshooting

"Agent not fingerprinted"

  • Ensure Pi is installed: npm install -g @mariozechner/pi-coding-agent
  • Check that the pi command is in PATH
  • If using a version manager (mise, nvm, bun), ensure the corresponding runtime is active
  • Run pi --version manually — it should print just a semver (e.g. 0.70.6)

"Session creation failed"

  • Check that Pi can run normally from a terminal
  • Verify a provider key is configured (e.g. ANTHROPIC_API_KEY) or that ~/.pi/agent/auth.json exists
  • Try echo "hello" | pi -p manually to confirm non-interactive mode works
  • Look at node logs for detailed errors

"Subsequent prompts started a new conversation"

  • The connector pins the session file after the first call. If the first call timed out before the session JSONL was flushed, the pin won't take effect and the next call will start fresh.
  • Ensure no concurrent pi processes are writing to the same cwd's sessions directory between turns.

Praxis Agent Connector

The Praxis Agent (short name praxis) is a native, provider-agnostic LLM connector that runs entirely on the node. Unlike other connectors which wrap an external CLI or browser-based agent, the Praxis Agent has no external binary to fingerprint — the node itself is the agent. It talks directly to a configured LLM endpoint, streams output back over ACP, and exposes a single run_command tool for executing shell commands on the host.

Overview

The Praxis Agent fills a different niche than the other connectors:

  • No external dependency — the node binary already contains everything needed. There's nothing to install on the target.
  • Cross-platform — works on Linux, Windows, and macOS without per-OS adaptation.
  • Service-configured — model selection, thinking effort, system prompt, and the on/off toggle live in the service database. Changes are pushed to nodes via a broadcast and are immediately reflected on subsequent sessions.
  • Provider-agnostic — uses the shared common::ai client, so any provider supported by Praxis (Anthropic, OpenAI, Gemini, OpenRouter, Fireworks, custom OpenAI-compatible endpoints, etc.) works.

The connector is disabled by default. It only appears in the agent registry once you turn it on under Settings → Agents → Praxis Agent and pick a model definition.

Configuration

All Praxis Agent settings live service-side in the service_config table:

KeyTypeDescription
praxis_agent_settingsJSON{ "modelRef": "<model-name>", "thinkingEffort": "<low|medium|high>", "enabled": true|false }
praxis_agent_system_prompttextOptional custom system prompt. Falls back to the built-in default when empty.

modelRef points to a row in your Settings → LLM → Models list. The service resolves it into a concrete PraxisAgentConfig (provider, API key, endpoint URL, model name) before pushing to the node.

thinkingEffort is a free-form string forwarded to the model as a sentence appended to the system prompt (e.g. "Requested thinking effort: medium."). Native API thinking budgets are not yet wired up; this is best-effort for models that respond to such hints.

enabled toggles registration. When false (or when the referenced model can't be resolved) the connector is not added to the registry.

UI

The praxis TUI exposes these controls under Settings (Ctrl+S) → Agents → Praxis Agent:

  • Enabled toggle.
  • Model dropdown (populated from the LLM Models list).
  • Thinking Effort input (free-form text).
  • System Prompt editor (opens in your $EDITOR).

Configuration flow

                    Service                                        Node
   ┌────────────────────────────────────┐         ┌─────────────────────────────────────┐
   │ Settings change                    │         │                                     │
   │   praxis_agent_settings updated    │         │ NodeState.factory_config            │
   │            │                       │         │   .praxis_agent_config: Option<…>   │
   │            ▼                       │         │            │                        │
   │ resolve_praxis_agent_config()      │         │            ▼                        │
   │            │                       │         │ AgentFactory.create_all_agents()    │
   │            ▼                       │         │   if Some(cfg) { push PraxisAgent } │
   │ broadcast PraxisAgentEnabled       │ ──────► │            │                        │
   │   { enabled, config }              │         │            ▼                        │
   │                                    │         │ AgentRegistry rebuilt; "praxis"     │
   │ -- on registration --              │         │ entry appears (or disappears).      │
   │ NodeRegistrationAck carries the    │         │                                     │
   │ same {enabled, config} payload.    │         │                                     │
   └────────────────────────────────────┘         └─────────────────────────────────────┘

The node never inspects ACP _meta for the agent's credentials. Whatever a session reaches, the agent already has its config baked in from the broadcast or registration ack. This keeps the ACP dispatch path transparent and the credential surface narrow.

Tools

The Praxis Agent exposes a single tool today:

run_command

Execute a shell command on the host.

ArgumentTypeDescription
commandstring (required)Shell command. Run with sh -c on Unix, cmd /C on Windows.
working_dirstring (optional)If non-empty, sets the child process's cwd.

Output format:

exit_code: <code or "terminated by signal">
stdout:
<captured stdout>
stderr:
<captured stderr>

Limits:

  • Wall-clock deadline (default 60s, override via PraxisAgentConfig.command_timeout_secs). On timeout the child is killed and the tool result is reported as an error.
  • Cancellation: shares the NodeSession.cancel_flag. session/cancel kills the running command within ~1s.

There is no permission gate; the agent runs every tool call it produces. Treat the Praxis Agent the same way you'd treat any agent with shell access — only enable it on hosts where that's intended.

Tool calling

The agent currently uses manual tool-call parsing: a system-prompt rule teaches the model to emit tool invocations as a JSON block ({"tool": "run_command", "args": {…}}) and parse_manual_tool_call extracts them from the streamed text. Native function calling (Anthropic tool_use, OpenAI tools) is a planned follow-up — it requires extending common::ai::ChatCompletionRequest with a typed tools field across all providers.

Practical implication: the raw tool-call JSON streams to the user as text alongside the structured ToolCall notification. Clients render the structured event as an inline tool call (with status updates), but the JSON itself is also visible in the chunk stream.

Session lifecycle

session/new ──► PraxisAgent.create_session_with_id(config)
                 │
                 ▼
              PraxisAgentSession
                 │  (handle = "praxis-<session-uuid>")
                 ▼
session/prompt ─► handler registers SessionUpdateKind sender on `handle`
                 │
                 ▼
              PraxisAgentSession.transact()
                 │  (block_on transact_async)
                 ▼
              ┌─────────────────────────────────────────┐
              │  Loop until no tool call or max iters   │
              │                                         │
              │  1. ChatCompletionRequest.stream()      │
              │     ├──► TextChunk per delta           │
              │     └──► Append to assistant message   │
              │                                         │
              │  2. parse_manual_tool_call(full_text)   │
              │     ├──► None: persist text, return    │
              │     └──► Some(tool):                   │
              │           ├── ToolCall update          │
              │           ├── Run tool (run_command)    │
              │           ├── ToolResult update         │
              │           └── Append tool result        │
              └─────────────────────────────────────────┘
                 │
                 ▼
session/close ─► PraxisAgentSession.close()
                 └─► cleanup_channels(handle)

Streaming

Output is forwarded over the same crate::acp::register_update_sender channel that ACP-backed Lua sessions use. The session emits common::SessionUpdateKind events:

  • TextChunk { text } — per-delta text from the LLM stream.
  • ToolCall { tool_name, tool_id, input } — structured tool invocation.
  • ToolResult { tool_id, output, is_error } — outcome of the tool.

The node ACP handler translates each event into the appropriate session/update JSON-RPC notification.

Conversation history

The session keeps a persistent message log across transact() calls, so multi-turn chats see prior exchanges. The model's actual streamed assistant text (including any tool-call JSON) is what gets written into history — not a synthesized summary — so the next turn sees what the user saw.

Cancellation

session/cancel sets the NodeSession.cancel_flag. The session starts with a fresh, local cancel flag at construction, and adopts the caller-supplied flag at the start of each transact_with_context call (this connector implements set_cancel_flag itself rather than relying on the trait default), so:

  • The chat_completion_stream loop checks it per delta.
  • The tool-call branch checks it before launching run_command.
  • run_command polls it once per second and kills the child.

Configuration knobs (per-config)

PraxisAgentConfig carries the per-node configuration:

FieldTypeDefaultNotes
providerstring(from model def)anthropic, openai, gemini, openrouter, etc.
apiKeystring(from model def)Forwarded to the provider client.
endpointUrlstring(from model def)Trailing slashes trimmed.
modelNamestring(from model def)Provider-specific model id.
systemPromptstring?built-in defaultCustom prompt, set via praxis_agent_system_prompt.
thinkingEffortstring?noneAppended to the system prompt as a sentence.
maxToolIterationsu32?10Cap on tool-call iterations per transact.
commandTimeoutSecsu64?60run_command wall-clock deadline.

Wire format is camelCase. The two configurable limits (maxToolIterations, commandTimeoutSecs) are reserved for future plumbing; today they're hardcoded defaults exposed in the schema for easy override.

Architecture notes

  • The Praxis Agent is constructed by AgentFactory on every registry rebuild. Whenever the service pushes a fresh PraxisAgentEnabled { enabled, config }, the runtime calls factory.set_config(...) and rebuilds — meaning configuration changes are picked up at the next rebuild without restarting the node.
  • PraxisAgentSession lives in node/src/agent_connectors/praxis/session.rs. It implements the same AgentSession trait as Lua sessions, exposing acp_handle() so the ACP handler treats both kinds of streaming sessions uniformly.
  • There is no fingerprinting step (do_fingerprint returns true unconditionally) and no version (the connector is part of the node binary). The agent simply appears in the node's agent list when configured.

Differences from Lua connectors

Lua connectorsPraxis Agent
External dependencyYes (e.g. claude, cursor, pi)None
FingerprintingProbes for binaryAlways available
Version reportingExtracted from binaryNone
ConfigurationDetected from agent config filesPushed by Praxis service
Session backendCLI (PTY) / DevTools / ACP-via-LuaACP-native streaming
Tool catalogWhatever the agent natively exposesrun_command only (today)

Troubleshooting

The praxis connector doesn't appear on a node

  • Check Settings → Agents → Praxis Agent → Enabled is on.
  • Check that a model is selected and that the model definition has a non-empty endpoint URL (or a provider whose default endpoint resolves).
  • Watch the service log on save — if the resolved config is None you'll see Praxis agent is enabled but its selected model could not be resolved.
  • The runtime logs Received PraxisAgentEnabled: enabled (config: present) when the broadcast arrives. If you only see (config: absent), the resolution failed.

Sessions stream raw JSON tool-call blocks alongside the structured tool call

Expected with the current manual tool-call parser. The structured ToolCall event is what UIs render inline; the raw JSON is part of the underlying assistant text. Native function calling will eliminate this once landed.

run_command cancellation is slow

Cancellation polls every second. A command that's stuck in a syscall longer than that will exit on the next poll. If the host is unresponsive, the kill signal may take longer to propagate.

"maximum Praxis agent tool iterations (10) reached"

The model emitted a tool call on every iteration without producing a final response. This usually means the prompt is too open-ended or the tool result keeps prompting another tool call. Increase maxToolIterations (planned UI), narrow the prompt, or inspect the conversation log on the next turn.

Architecture Overview

Praxis has a distributed architecture designed for monitoring and controlling AI agents across multiple systems. Let's walk through how the pieces fit together.

The Big Picture

OPERATORS & AI CLIENTS
┌──────────────────┐  ┌──────────────────┐  ┌──────────────────┐
│    MCP clients   │  │ Praxis TUI / CLI │  │  LLM providers   │
└──────────────────┘  └──────────────────┘  └──────────────────┘
         │                     │                     │
         │                     ▼                     │
         │            ┌──────────────────┐           │
         │            │     RabbitMQ     │           │
         │            └──────────────────┘           │
         │ HTTP · /mcp         │ AMQP                │ LLM API
         ▼                     ▼                     ▼
┌──────────────────────────────────────────────────────────────┐   ┌──────────────────┐
│                        Praxis Service                        │◄─►│ Persistent state │
├────────────────────┬────────────────────┬────────────────────┤   └──────────────────┘
│    ACP gateway     │    Control plane   │     Automation     │
└────────────────────┴────────────────────┴────────────────────┘
                               │   AMQP
         ┌─────────────────────┼─────────────────────┐
         ▼                     ▼                     ▼
┌──────────────────┐  ┌──────────────────┐  ┌──────────────────┐
│  Node · Target A │  │  Node · Target B │  │  Node · Target N │
└──────────────────┘  └──────────────────┘  └──────────────────┘
                  DISTRIBUTED TARGET ENDPOINTS

Three paths reach the service, and only one of them is AMQP:

  • Praxis TUI / CLI — over RabbitMQ. This is the operator path.
  • MCP-compatible clients — straight to the service over streamable HTTP at /mcp, no broker involved. See MCP Server.
  • LLM providers — outbound calls made by the service, never by a node.

Everything below the service is AMQP: node commands, ACP frames, and telemetry all ride RabbitMQ queues. Nodes never talk to clients directly.

The boxes are deliberately shallow. What sits inside each one is covered by Service Architecture, Node Architecture, and Agent Connectors.

Components

Node

The node runs on target systems where AI agents are installed. It's the "eyes and hands" of Praxis on each endpoint.

What it does:

  • Fingerprints installed agents
  • Performs reconnaissance on agent configurations and sessions
  • Intercepts traffic between agents and LLM backends
  • Creates and manages sessions with agents
  • Provides PTY terminal access to the system

Key characteristics:

  • Stateless - all persistent data lives on the service
  • Single binary, no dependencies
  • Communicates with service over RabbitMQ

See Node Architecture for details.

Service

The service is the central backend that coordinates everything.

What it does:

  • Tracks all connected nodes and their agents
  • Stores configuration, operation definitions, and chain workflows
  • Manages the semantic operations queue
  • Executes chains by orchestrating multi-step workflows
  • Persists intercepted traffic and recon results
  • Handles LLM provider integrations

Key characteristics:

  • Persistent storage (SQLite default, PostgreSQL for production)
  • Stateful - knows about all nodes and their state
  • Runs the operation manager and chain executor

See Service Architecture for details.

Communication

No direct client↔node traffic

The service is the only component that talks to nodes. Clients (the praxis TUI and external ACP tools) speak to the service; the service forwards to the relevant node over RabbitMQ. This keeps access control, session routing, and request correlation in one place and means node failure modes never leak into clients.

 praxis TUI ─▶ RabbitMQ ─▶ Service ─▶ RabbitMQ ─▶ Node
 External ACP client ─▶

ACP (Agent Client Protocol)

Each node exposes a single ACP server (node/src/acp_server/) over RabbitMQ. That one endpoint is how every local agent on the node is driven — the connector to use is selected per-session via _meta.praxis.connector on the session/new request. Multiple concurrent sessions are supported on the same node, each with its own freshly-built Lua VM.

The service-side proxy (service/src/acp_node_proxy.rs) routes frames:

  • External client → service → _meta.praxis.nodeId → target node.
  • Node → service → originating client (by correlated client_id).
  • Service's internal orchestrator → node, using a svc_* pseudo-client-id so responses are consumed in-process instead of being forwarded.

Recon is a custom ACP extension (_praxis/recon) plus four file-op extensions (_praxis/read_file, _praxis/write_file, _praxis/grep_files, _praxis/write_session_content). The node advertises them in InitializeResponse._meta.extensions along with the connector catalog.

RabbitMQ

All communication between nodes, service, and clients flows through RabbitMQ:

QueueDirectionPurpose
NodeSignalNode → ServiceRegistration, traffic, recon results, outbound ACP frames
Node_{id}Service → NodeCommands, parser responses, inbound ACP frames
NodeBroadcastService → All NodesRefresh requests (fanout exchange)
ClientSignalClient → ServiceUI requests, inbound ACP frames
Client_{id}Service → ClientDirect responses, outbound ACP frames
ClientBroadcastService → All ClientsState updates (fanout exchange)

RabbitMQ provides:

  • Reliable message delivery
  • Decoupling between components
  • Easy scaling (nodes can come and go)
  • Persistence for messages in flight

Named queues (NodeSignal, ClientSignal, Node_{id}, Client_{id}, event-log queues, etc.) are declared durable so their definitions survive broker restarts and stay compatible with RabbitMQ 4.3+, which denies transient non-exclusive classic queues by default. Per-connection broadcast queues remain exclusive + auto-delete (not subject to that deprecation).

Message Flow Example

Here's what happens when a CLI driver runs a prompt over ACP:

  1. CLI (ACP proxy) → ClientSignalService
  2. Service (AcpNodeProxy) sees _meta.praxis.nodeId, forwards the raw JSON-RPC frame via Node_{id}Node
  3. Node (NodeAcpServer) processes session/new / session/prompt / etc., running on a per-session Lua VM
  4. Node emits response + session/update notifications on NodeSignal
  5. Service (AcpNodeProxy::forward_to_client) routes them to the originating Client_{id} queue
  6. CLI reads responses from its client queue and emits them on stdout

Data Flow

Intercepted Traffic

Agent ─HTTPS─▶ Proxy ─▶ Node ─RabbitMQ─▶ Service ─▶ Database
                                           │
                                           └─RabbitMQ─▶ praxis TUI

Operations

praxis TUI ─▶ Service ─▶ LLM (planning)
                 │
                 └─▶ Node ─▶ Agent (execution)
                       │
                       └─▶ Output ─▶ Service ─▶ praxis TUI

Database Schema

The service stores everything in a relational database:

  • service_config - key-value settings (LLM configs, etc.)
  • operation_definitions - saved operation templates
  • operations - operation execution history
  • operation_chains - workflow definitions
  • chain_executions - workflow execution history
  • intercepted_traffic - intercepted HTTP traffic
  • intercept_rules - traffic matching rules
  • recon_results - cached reconnaissance data
  • event_log - centralized logging (controlled by application_logs_enabled)

This list is illustrative, not exhaustive - roughly 10 more tables exist (e.g. session_transactions, chain_triggers, chain_memories, chain_payloads, lua_agent_scripts, toolkit_actions, remote_nodes, agent_chat_sessions/agents/channels/messages).

Deployment Patterns

Development

Single machine running everything:

  • Docker Compose with service and RabbitMQ
  • Node running locally for testing

Production

Separate concerns:

  • Service on central server
  • RabbitMQ (possibly managed service)
  • Nodes deployed to target systems
  • PostgreSQL for the database

Cloud (Azure)

See Azure Deployment:

  • Container Apps for the service
  • Managed RabbitMQ or Container Instance
  • Azure Database for PostgreSQL

Node Architecture

The node is the component that runs on target systems. It's responsible for all local interactions with AI agents.

Overview

┌──────────────────────────────────────────────────────────────┐
│                            Node                              │
│                                                              │
│  ┌────────────────┐  ┌────────────────┐  ┌────────────────┐  │
│  │ Agent Registry │  │ Intercept Mgr  │  │  Terminal Mgr  │  │
│  │                │  │                │  │                │  │
│  │ ┌────────────┐ │  │ ┌────────────┐ │  │ ┌────────────┐ │  │
│  │ │ Connector  │ │  │ │   Proxy    │ │  │ │    PTY     │ │  │
│  │ ├────────────┤ │  │ ├────────────┤ │  │ └────────────┘ │  │
│  │ │ Connector  │ │  │ │  TUN/VPN   │ │  │                │  │
│  │ ├────────────┤ │  │ ├────────────┤ │  └────────────────┘  │
│  │ │ Connector  │ │  │ │  TPROXY    │ │                      │
│  │ └────────────┘ │  │ ├────────────┤ │                      │
│  └────────────────┘  │ │   Hosts    │ │                      │
│                      │ └────────────┘ │                      │
│                      └────────────────┘                      │
│                                                              │
│  ┌────────────────────────────────────────────────────────┐  │
│  │               Runtime / Message Handler                │  │
│  └────────────────────────────────────────────────────────┘  │
│                              │                               │
│                         RabbitMQ                             │
└──────────────────────────────┼───────────────────────────────┘
                               │
                          To Service

Agent Registry

The agent registry manages all supported agent connectors. On startup the registry is built via rebuild() which:

  1. Creates native agents from the factory (currently unused; all agents are Lua-based)
  2. Loads Lua connectors from the service (delivered in the RegistrationAck message)
  3. Falls back to embedded Lua scripts if no service scripts are provided

The service includes all stored Lua scripts in the NodeRegistrationAck sent to the node's direct queue during registration. This avoids a race condition where a fanout broadcast could arrive before the node's exchange consumer is ready. On re-registration (e.g. after connection loss), scripts are also delivered via the ack.

Subsequent script changes (add/edit/delete via the praxis TUI Settings → Agents tab) are broadcast to nodes via AgentRegistryUpdate on the fanout exchange.

Updates are session-gated: if a session is open when an update arrives, it is queued and applied after the session closes. If multiple updates arrive while a session is open, only the latest is kept.

Fingerprinting

Fingerprinting runs --version on each agent binary to verify availability and extract the version string. There is no result cache - every agent is re-checked live on each fingerprint pass, so an agent uninstalled since the last check is never reported as available. The service triggers a fingerprint pass on each node roughly every 30 seconds via a NodeInformationUpdateRequest broadcast.

Development Builds

In debug builds, PRAXIS_IGNORE_SERVICE_AGENTS=1 (the default) causes the node to ignore service-pushed scripts and use only embedded Lua scripts. Set to 0 to test with service-managed scripts.

Intercept Manager

The intercept manager handles traffic capture. It supports four methods:

Proxy Mode

Configures system proxy settings to route HTTP/HTTPS through a local proxy:

  • Linux: Sets HTTP_PROXY and HTTPS_PROXY environment variables
  • Windows: Modifies registry proxy settings

The proxy terminates TLS using a generated root CA, captures traffic, then re-encrypts and forwards to the actual destination.

VPN Mode

Creates a TUN adapter and routes specific IPs through it:

  1. TUN device created (wintun on Windows, tun crate on Linux)
  2. Intercept domains resolved to IP addresses
  3. Routes added through the TUN interface
  4. Packet engine performs NAT to redirect to local proxy

This captures traffic even from applications that ignore proxy settings.

Hosts Mode

Modifies the hosts file to redirect domains to localhost:

  • Adds entries for intercept domains
  • Proxy listens and handles redirected traffic
  • Simpler but less flexible than VPN mode

TPROXY Mode (Linux)

Uses iptables TPROXY for transparent interception:

  1. Intercept domains resolved to IP addresses
  2. iptables mangle rules mark packets to target IPs
  3. Policy routing directs marked packets to loopback
  4. TPROXY redirects packets to proxy
  5. Proxy uses the accepted socket local_addr (IP_TRANSPARENT) as the real destination

This provides kernel-level interception without a TUN device.

Certificate Authority

All methods use a generated CA:

  1. Root CA created with unique key
  2. Root cert installed in system trust store
  3. Leaf certificates generated per domain
  4. TLS termination with valid-looking certs

Multi-User Support

When the node runs as root, it provides multi-user support:

User Enumeration

The node scans all user home directories (/home/* and /root) to discover:

  • Agent configurations (e.g., .claude/, .gemini/, .codex/)
  • Project directories with agent config files
  • Session history files

This allows a single node running as root to manage agents across all users on the system.

User-Aware Session Execution

When a session is created with a working directory owned by a non-root user, the node automatically:

  1. Determines the directory owner's uid/gid
  2. Sets the HOME environment variable to the user's home directory
  3. Spawns the agent process as that user

This ensures the agent:

  • Has appropriate file permissions for the project
  • Reads its config from the correct user's home directory
  • Creates files owned by the correct user

Security Considerations

  • Path validation ensures file operations stay within valid home directories
  • Config file access is restricted to enumerated user homes
  • The node validates all paths before reading or writing

Session Management

Sessions allow direct interaction with agents:

CLI Agents (PTY)

  1. PTY created for the agent process
  2. Agent spawned with appropriate flags (and as appropriate user when running as root)
  3. Prompts written to stdin
  4. Responses read from stdout
  5. Output parsed and returned

CLI Agents (ACP)

Agents that support the Agent Client Protocol (Cursor, Gemini) use a long-lived subprocess with JSON-RPC 2.0 over NDJSON stdio instead of PTY. The node uses the agent-client-protocol crate's builder API (acp::Client::builder()) for typed, async communication:

  1. Agent spawned with ACP flag (e.g. cursor-agent acp, gemini --acp) via tokio::process::Command
  2. A byte-stream transport (acp::ByteStreams) wraps the subprocess stdin/stdout, and the builder connects over it via connect_with
  3. Initialize handshake via typed InitializeRequest/InitializeResponse
  4. Prompts sent via typed PromptRequest, responses received as PromptResponse with StopReason
  5. Real-time streaming updates (SessionUpdate variants: text chunks, tool calls, tool results, plans) delivered via an on_receive_notification callback registered on the builder
  6. Permission requests handled via an on_receive_request callback registered on the builder
  7. Cancellation via CancelNotification

The driver runs on a dedicated OS thread with its own single-threaded (current_thread) Tokio runtime, spawned by spawn_acp_client, so synchronous Lua callers can block on replies without occupying the caller's runtime. An AcpHandle provides a Send-safe interface for the Lua runtime via channels.

Browser-based Agents

  1. App with webview launched with debugging enabled (on a hidden desktop in release builds; visible in debug builds by default)
  2. CDP connection established via chromiumoxide
  3. Prompts injected via DOM manipulation (InsertText + Enter)
  4. Responses polled from page via JavaScript evaluation
  5. Abort kills the entire process tree; Drop safety net cleans up even on Lua errors

Session Context

Sessions are created with:

  • Working directory - where the agent operates
  • YOLO mode - auto-approve tool calls
  • Interactive - whether permission requests should be forwarded to the user (TUI) or auto-denied (MCP/orchestrator)

Terminal Manager

Provides PTY terminal access to the target system:

  1. Shell spawned (bash/zsh/powershell)
  2. PTY handles input/output
  3. Terminal data streamed to the praxis TUI
  4. Supports resize, Ctrl+C, etc.

Message Handling

The node speaks two protocols over RabbitMQ. Agent and session interaction use ACP (Agent Client Protocol). Everything else — intercept, terminal, config, registration — uses the bespoke NodeCommand envelope.

ACP (node-as-agent)

The node runs its own ACP server (node/src/acp_server/) and appears to the service as a single ACP-speaking agent. The service forwards client ACP frames to the node over RabbitMQ via NodeDirectMessage::Acp(AcpFrame); responses and notifications flow back via NodeSignalMessage::Acp.

Standard ACP methods supported:

  • initialize — capability handshake. The node advertises the connector catalog and supported extensions in InitializeResponse._meta:
    {
      "extensions": { "_praxis/recon": { "version": 1 } },
      "connectors": [ { "shortName": "claudecode", "name": "Claude Code" }, ... ],
      "nodeId": "..."
    }
    
  • session/new — create a session. The target connector is selected via _meta.praxis.connector. Session options (yolo, promptTimeoutSecs, interactive) also live under _meta.praxis:
    {
      "cwd": "/path",
      "_meta": {
        "praxis": {
          "connector": "claude-code",
          "yolo": false,
          "promptTimeoutSecs": 600,
          "interactive": true
        }
      }
    }
    
  • session/prompt — send a prompt to the named session.
  • session/cancel — cancel an in-flight prompt.
  • session/close — terminate and release the session's per-session Lua VM.
  • session/list — enumerate live sessions on the node.

Multiple concurrent sessions are supported. Each session owns a freshly instantiated Lua VM (loaded from connector bytecode compiled once at connector-load time), so no Lua-level state leaks between sessions sharing the same connector script.

ACP extensions

All are agent-scoped custom ACP methods (no session_id required) and are advertised in InitializeResponse._meta.extensions:

  • _praxis/recon — reconnaissance. Params { "agent_short_name": string, "is_semantic": bool }; returns a ReconResult with three categories: config (config items + project paths), tools (MCP servers + skills, plus internal/built-in tools when is_semantic is true), sessions (enumerated agent sessions).
  • _praxis/read_file, _praxis/write_file, _praxis/grep_files — agent-scoped file ops used by recon tooling and the orchestrator.
  • _praxis/write_session_content — writes agent-session content through the connector's write_session_content hook so agents with virtual session stores can intercept the write.

NodeCommand (non-agent concerns)

#![allow(unused)]
fn main() {
pub enum NodeCommand {
    Intercept(InterceptCommand),
    Terminal(TerminalCommand),
    Config(ConfigCommand),
    AgentRegistry(AgentRegistryCommand),
}
}

Agent and session interaction have moved off NodeCommand entirely. The legacy NodeCommand::Agent and NodeCommand::Session variants — along with NodeSignalMessage::ReconResultUpdate and ::SessionUpdate — were removed once the CLI, service orchestrator, and MCP server had all been ported to ACP.

Intercept Commands

  • Enable - start interception with specified method
  • Disable - stop interception and cleanup

State Management

The node is mostly stateless-it reports to the service but doesn't persist data locally. However, some state is maintained:

Intercept State

Saved to disk for crash recovery:

  • Active interception method
  • Installed certificate info
  • Modified system settings

On restart, the node cleans up stale state.

Session State

Kept in memory:

  • Live ACP sessions keyed by session_id, each with its own Lua VM and cancellation flag
  • PTY handles
  • Transaction tracking

Node Reset

A node can be reset at any time via the UI, CLI (node reset), or MCP (node_reset). Reset cancels in-flight intercept enable via an operation token, awaits the command handler so partial enable can roll back, closes sessions and terminals, runs intercept force_cleanup when needs_cleanup() (including CleanupRequired), and re-registers the node with the service — equivalent to a clean restart without killing the process.

The reset signal is delivered on a dedicated RabbitMQ queue (Node_{id}_reset) consumed by its own task. This guarantees the signal is never blocked by a long-running command handler in the main event loop. When the reset consumer receives a message it cancels a CancellationToken that the main loop observes. Async intercept phases (DNS, proxy start, packet IP refresh) are raced against the operation token. Synchronous OS child commands (firewall, sysctl, cert store, etc.) cannot be interrupted mid-process; cancel is checked at phase boundaries and incomplete cleanup leaves CleanupRequired with recovery state retained rather than silently Disabled.

After cleanup the runtime returns RuntimeExit::Reset and the main reconnection loop immediately re-registers without the usual reconnect delay.

Intercept lifecycle

StateMeaning
DisabledNo ownership; enable allowed if no stale recovery
EnablingSetup in progress
EnabledProxy/VPN/Hosts/TPROXY active
CleanupRequiredRollback incomplete; re-enable blocked until Disable/Reset cleanup succeeds

InterceptStatus.cleanup_required surfaces this to service/CLI status (e.g. praxis intercept status); the Intercept TUI window does not enumerate per-node pills.

Registration

When the node starts:

  1. Generates unique node ID (or uses existing)
  2. Collects system information
  3. Runs agent fingerprinting
  4. Sends registration to service
  5. Begins processing commands

Periodic updates report current state to the service.

Minimal C Node (node/tiny_c)

Alongside the full Rust node, the repo ships a pure-C minimal node at node/tiny_c/. It is parity-equivalent in scope with the Praxis ACP session path only — useful when you need a tiny, dependency-free agent that registers as a node and serves ACP sessions against an OpenAI-compatible API.

  • Runtime deps: libc + libpthread only. AMQP 0-9-1, JSON, HTTP/1.1 and the ACP JSON-RPC framing are hand-rolled. TLS comes from BearSSL (MIT), downloaded and statically linked at build time.
  • Size: make release produces a stripped binary around ~230 KB on x86_64 glibc (~50 KB node code + ~180 KB BearSSL + trust anchors).
  • Build: make (debug) or make release from node/tiny_c/. First build fetches and compiles BearSSL into vendor/ and generates src/trust_anchors.inc from the system CA bundle.
  • Run: PRAXIS_RABBITMQ_URL=amqp://praxis:praxis@host:5672/ ./praxis_node_tiny_c. The node id is persisted to ~/.local/share/praxis/node_id.

Limitations versus the full node:

  • Linux only. Uses /dev/urandom, gethostname(2), sigaction, select(2).
  • OpenAI-compatible chat-completions only. No Anthropic / Gemini provider plumbing, no Lua connectors, no MCP, no intercept, no terminal capability, no event-log forwarder, no semantic-parser integration.
  • Single in-flight prompt per session — concurrent prompts on the same session return JSON-RPC error -32603 until the active worker finishes.
  • Advertises only the Session capability to the service.

See node/tiny_c/README.md for the full layout, wire-protocol notes, and build details.

Service Architecture

The service is the central backend that coordinates nodes, manages data, and orchestrates operations. It is the only component that talks to nodes — clients (the TUI and external ACP tools) always reach nodes through the service's ACP server and proxy layer.

Overview

┌──────────────────────────────────────────────────────────────┐
│                           Service                            │
│                                                              │
│  ┌────────────────┐  ┌────────────────┐  ┌────────────────┐  │
│  │  Node Tracker  │  │  Semantic Ops  │  │     Chain      │  │
│  │                │  │    Manager     │  │    Executor    │  │
│  │  node_1 ─────┐ │  │                │  │                │  │
│  │  node_2 ─────┤ │  │  queue ─────┐  │  │  workflow ──┐  │  │
│  │  node_3 ─────┘ │  │  executor ──┘  │  │  steps ─────┘  │  │
│  └────────────────┘  └────────────────┘  └────────────────┘  │
│                                                              │
│  ┌────────────────┐  ┌────────────────┐  ┌────────────────┐  │
│  │   Trigger      │  │   LLM Client   │  │    Message     │  │
│  │   Engine       │  │                │  │   Processor    │  │
│  │  scheduler ────│  │  providers ────│  │                │  │
│  └────────────────┘  └────────────────┘  └────────────────┘  │
│                                                              │
│  ┌────────────────┐                                          │
│  │    Database    │                                          │
│  │  SQLite/PG ────│                                          │
│  └────────────────┘                                          │
│                                                              │
│                         RabbitMQ                             │
└─────────────────────────────┬────────────────────────────────┘
                              │
                              │
                        Nodes and Clients

ACP server and node proxy

The service hosts an ACP server (service/src/acp_server.rs) that external clients speak to. When a client frame carries _meta.praxis.nodeId or names a session_id the service has mapped to a node, the AcpNodeProxy (service/src/acp_node_proxy.rs) forwards the frame over RabbitMQ to the target node's ACP server. Responses and session/update notifications flow back the same way.

The service's internal orchestrator subsystems (e.g. tools, semantic_ops, claude_bridge) also drive nodes through this same proxy, using AcpNodeProxy::request / request_collecting_text. Internal callers get a svc_* pseudo-client-id so their responses are completed in-process instead of being delivered to any external client queue.

Node Tracking

The service maintains state for all connected nodes:

#![allow(unused)]
fn main() {
struct NodeState {
    node_id: String,
    node_type: String,
    capabilities: Vec<NodeCapability>,
    machine_name: String,
    os_details: String,
    discovered_agents: Vec<DiscoveredAgent>,
    selected_agent: Option<SelectedAgent>,
    intercept_active: bool,
    intercept_supported: bool,
    intercept_status: Option<InterceptStatus>,
    last_update: DateTime<Utc>,
    status: NodeStatus,
    active_terminal_id: Option<String>,
    privileged: bool,
}
}

Registration

When a node registers:

  1. Node info stored/updated
  2. Agent list recorded
  3. Acknowledgment sent with node-specific queue name
  4. Node subscribes to broadcast exchange
  5. Service broadcasts current application_logs_enabled state to nodes and clients

Health Monitoring

Nodes send periodic updates. If a node goes silent:

  • Marked as potentially offline
  • Can be manually removed from UI
  • Automatic cleanup after timeout

Semantic Operations Manager

Handles execution of semantic operations through agents:

Operation Queue

Operations are queued per node:

  • One operation runs at a time per node
  • FIFO ordering
  • Can cancel queued or running operations

Execution Modes

One-Shot Mode:

  1. Operation prompt sent directly to agent session
  2. Agent executes and responds
  3. Response captured and returned

Agent Mode:

  1. Operation sent to orchestrator LLM with system prompt
  2. Orchestrator determines action using session_prompt tool
  3. Action executed via agent
  4. Result returned to orchestrator
  5. Repeat until complete or max iterations

System Prompts

Agent mode uses system prompts embedded at build time:

PromptLocationPurpose
Semantic Op Agentservice/src/prompts/semantic_op_agent.promptOrchestrator behavior
Tool Callingcommon/src/prompts/tool_calling.promptTool call JSON format
Task Completioncommon/src/prompts/task_completion.promptCompletion signal format

These prompts are compiled into the binary using include_str! and cannot be modified at runtime. This ensures consistent behavior and prevents prompt injection.

Model Override

Operations can specify a different LLM model than the default. The manager resolves the model reference and uses the appropriate provider.

Chain Executor

Executes multi-step workflows:

Chain Structure

Trigger → Element → Element → ... → Termination
             │
             └── Transform/Operation/Prompt

Execution Flow

  1. Chain triggered (manual, scheduled, or event-driven)
  2. Target spec resolved into concrete node/agent pairs
  3. For multi-target specs, the executor performs a fan-out (one execution per target)
  4. Elements executed in order following connections
  5. Output from each element passed to next
  6. Session groups maintain shared context
  7. Termination collects final output

Session Groups

Elements in the same session group share an agent session:

  • Maintains conversation context
  • Allows multi-turn interactions
  • YOLO mode can be set per group

Target Resolution

When a chain runs with a TargetSpec (from a trigger or advanced targeting), the targeting module resolves it into concrete (node_id, agent_short_name) pairs:

  1. List all registered nodes
  2. Filter by node_ids if non-empty
  3. Filter by os_filter (case-insensitive substring on OS details)
  4. If include_triggering_node is set, ensure the triggering node passes the filter
  5. For each surviving node, filter discovered agents by agent_short_names
  6. Skip agents that are not currently available
  7. Return the flattened list of resolved targets

Each resolved target gets its own independent chain execution.

Trigger Engine

The trigger engine automates chain execution based on configured triggers. It is initialized at service startup and runs for the lifetime of the service.

Trigger Types

#![allow(unused)]
fn main() {
enum TriggerConfig {
    Scheduled { schedule: ScheduleSpec, recurring: bool },
    InterceptMatch { rule_id: i64 },
    NewNode,
}

enum ScheduleSpec {
    DailyAt { hour: u8, minute: u8 },
    Interval { minutes: u32 },
}
}

Scheduler Loop

The engine runs a polling loop that checks for due scheduled triggers every 30 seconds. It also accepts refresh signals (via Notify) so that CRUD operations on triggers cause an immediate re-check.

For each due trigger:

  1. Load the associated chain definition
  2. Resolve the target spec against the current node registry
  3. Execute the chain via execute_fan_out for each resolved target
  4. Mark the trigger as fired (update last_fired_at, recompute next_fire_at)
  5. If the trigger is non-recurring, disable it after firing

Event-Driven Triggers

Event triggers fire outside the polling loop, in direct response to events:

InterceptMatch - When intercepted traffic matches an intercept rule, the node dispatch handler calls fire_intercept_match_triggers(). The engine looks up all enabled InterceptMatch triggers whose rule_id matches, applies a 60-second debounce per trigger, and fires matching chains.

NewNode - When a node registers, the node dispatch handler spawns a delayed task (10 seconds to allow agent discovery) that calls fire_new_node_triggers(). The engine fires all enabled NewNode triggers with the registering node ID as the triggering node.

Trigger Storage

Triggers are stored in the chain_triggers database table with JSON-serialized trigger_config and target_spec columns. The engine queries this table for due triggers and event-based triggers, and updates it after firing.

Database

The service uses SQLAlchemy-style database abstraction supporting SQLite and PostgreSQL:

Schema

-- Configuration
CREATE TABLE service_config (
    key TEXT PRIMARY KEY,
    value TEXT
);

-- Operation definitions
CREATE TABLE operation_definitions (
    id INTEGER PRIMARY KEY,
    full_name TEXT UNIQUE,
    content TEXT,
    created_at TIMESTAMP,
    updated_at TIMESTAMP
);

-- Operation executions
CREATE TABLE operations (
    operation_id TEXT PRIMARY KEY,
    node_id TEXT,
    agent_short_name TEXT,
    operation_spec TEXT,
    status TEXT,
    start_time TIMESTAMP,
    end_time TIMESTAMP,
    output TEXT,
    created_at TIMESTAMP,
    -- ...
);

-- Intercepted traffic
CREATE TABLE intercepted_traffic (
    id INTEGER PRIMARY KEY,
    timestamp TIMESTAMP,
    node_id TEXT,
    agent_short_name TEXT,
    direction TEXT,
    url TEXT,
    request_body BLOB,
    response_body BLOB,
    -- ...
);

-- Lua agent scripts
CREATE TABLE lua_agent_scripts (
    id TEXT PRIMARY KEY,
    name TEXT,
    script TEXT,
    created_at TEXT,
    updated_at TEXT
);

-- Chain triggers
CREATE TABLE chain_triggers (
    id TEXT PRIMARY KEY,
    chain_id TEXT NOT NULL,
    trigger_config TEXT NOT NULL,    -- JSON: TriggerConfig
    target_spec TEXT NOT NULL,       -- JSON: TargetSpec
    enabled INTEGER DEFAULT 1,
    last_fired_at TEXT,
    next_fire_at TEXT,
    created_at TEXT NOT NULL,
    updated_at TEXT NOT NULL
);

-- Operation chains, chain executions, etc.

Connection

Default: SQLite at ~/.praxis/operations.db

For production: PostgreSQL via PRAXIS_DATABASE_URL

LLM Client

Handles communication with LLM providers:

Supported Providers

  • Anthropic (Claude)
  • OpenAI (GPT)
  • Google (Gemini)
  • Groq
  • Cerebras
  • Mistral
  • xAI
  • Ollama (local)
  • NVIDIA
  • MiniMax
  • Moonshot AI
  • Fireworks AI
  • OpenRouter
  • Custom (OpenAI-compatible)

Configuration

Stored in database as key-value pairs:

  • llm.semantic_ops.provider
  • llm.semantic_ops.model
  • llm.semantic_ops.api_key
  • (similar for other features)

Usage

Different features use different LLM assignments:

  • Semantic Operations - operation orchestration
  • Semantic Parser - tool discovery during recon
  • Traffic Parser - traffic summarization

Message Processing

The service processes messages from multiple queues:

Node Messages (NodeSignal)

  • Registration - node startup
  • InformationUpdate - periodic state update
  • CommandResponse - response to command
  • TerminalOutput - terminal session output
  • InterceptedTraffic - captured traffic
  • InterceptStatusUpdate - intercept status change
  • Acp - ACP JSON-RPC frame from the node's ACP server
  • SemanticParserRequest - parser request from node

Client Messages (ClientSignal)

  • Registration - client connection
  • Command - forward to node
  • SemanticOpRun - execute operation
  • ChainRun - execute chain
  • TrafficLogRequest - query traffic
  • Configuration and management requests

Broadcasts

The service sends broadcasts (fanout exchange) to keep all clients in sync:

  • StateUpdate - periodic full state
  • ChainExecutionUpdate - chain progress
  • ServiceOnline - service restart notification
  • EventLoggingSet - centralized logging toggle

Lua Agent Script Management

The service manages Lua agent connector scripts stored in the database. Default scripts from the agents/ directory are embedded at build time and seeded into the lua_agent_scripts table on first startup when the table is empty.

When a node registers, the service includes all Lua scripts in the NodeRegistrationAck message sent to the node's direct queue. This avoids a race condition where a fanout broadcast could arrive before the node's exchange consumer is ready.

Scripts can be added, updated, or deleted via the praxis TUI (SettingsAgents tab). When scripts change, the service broadcasts an AgentRegistryUpdate to all connected nodes so they reload the latest scripts.

A "Reset Defaults" operation clears all scripts and re-inserts the embedded defaults.

Agent version information (extracted during fingerprinting) is included in the DiscoveredAgent data reported by nodes and displayed in the praxis TUI.

Claude Bridge

The service can optionally run Claude Bridge listeners that accept inbound connections from Claude Code instances. Each connection creates a virtual node with an active session, allowing Claude to be controlled through Praxis without deploying a full node.

Two protocol versions are supported:

CCRv1 - WebSocket listener with bidirectional NDJSON. Simpler protocol, fewer requirements on the Claude side.

CCRv2 - HTTP server with SSE for server-to-client messages and POST for client-to-server messages. Includes epoch-based versioning and heartbeat-based disconnect detection.

Both bridges are managed by dedicated manager structs (CcrV1Manager, CcrV2Manager) that start and stop based on configuration changes. When enabled, they bind to their configured ports and accept connections. Each connection runs a BridgeSession that handles the protocol handshake, registers a virtual node via RabbitMQ, and relays messages between the Claude worker and the Praxis service.

Bridge nodes only support the Session capability. They do not support interception, recon, or terminal access. See Claude Bridge for protocol details and operator setup.

Startup Sequence

  1. Connect to RabbitMQ
  2. Declare queues and broadcast exchanges
  3. Load configuration from database
  4. Seed default Lua agent scripts (if table is empty)
  5. Start message consumers
  6. Initialize semantic ops manager
  7. Initialize chain executor
  8. Initialize trigger engine and start scheduler
  9. Start Claude Bridge listeners (if enabled)
  10. Request node re-registration (broadcast)
  11. Begin processing messages

Error Handling

The service handles various failure scenarios:

  • Node disconnect: State preserved, node can reconnect
  • RabbitMQ failure: Reconnection with backoff
  • LLM errors: Reported to operation caller
  • Database errors: Logged, operation may fail

Errors are logged and surfaced to the UI where appropriate.

Local Development

This guide is for contributors working on Praxis itself. To install Praxis, use the one-liner installer:

curl -fsSL https://praxis.originhq.com/install.sh | bash

See Installation for all install options.

Building from Source

Prerequisites

  • Rust 1.70+ with cargo, via rustup:
    curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
    
  • RabbitMQ running locally
  • Linux build dependencies (Debian/Ubuntu):
    sudo apt-get install -y build-essential pkg-config libssl-dev
    
    • build-essential provides cc/make/libc headers — without it the build fails with linker `cc` not found.
    • pkg-config + libssl-dev let openssl-sys (pulled in via native-tls) find the system OpenSSL install.

Build Steps

  1. Clone the repository:
git clone https://github.com/originsec/praxis.git
cd praxis
  1. Build the default workspace members:
cargo build --release

This builds the service, node, and TUI (praxis) components.

Cross-Compiling the Node for Windows

To build praxis_node.exe for Windows targets from a Linux host:

sudo apt-get install -y mingw-w64
rustup target add x86_64-pc-windows-gnu
cargo build --release --target x86_64-pc-windows-gnu -p praxis_node

mingw-w64 provides the x86_64-w64-mingw32-gcc cross-linker that cargo uses for this target. The resulting binary is at target/x86_64-pc-windows-gnu/release/praxis_node.exe. This is the same toolchain the installer uses for --with-win-node --src (see Installation).

Running Locally

Start RabbitMQ

If not using Docker:

# Linux
sudo systemctl start rabbitmq-server

Create the praxis user:

rabbitmqctl add_user praxis praxis
rabbitmqctl set_permissions -p / praxis ".*" ".*" ".*"

Start the Service

cargo run --release --bin praxis_service

The service starts and connects to RabbitMQ, creating necessary queues.

Start a Node

For testing locally, run a node on your own machine:

cargo run --release --bin praxis_node

The node connects to RabbitMQ and registers with the service.

Environment Variables

Configure via environment or .env file:

VariableDefaultDescription
PRAXIS_RABBITMQ_URLamqp://praxis:praxis@localhost:5672RabbitMQ connection
PRAXIS_DATABASE_URL~/.praxis/operations.dbDatabase path
RUST_LOGinfoLog level

Database Options

SQLite is used by default with no configuration required.

For PostgreSQL or advanced configuration, see Database Configuration.

Development Workflow

Code Changes

  1. Make changes to Rust code
  2. Rebuild: cargo build
  3. Restart affected component

Testing

Run tests:

cargo test

Logs

Adjust log verbosity:

RUST_LOG=debug cargo run --bin praxis_service
RUST_LOG=praxis_node::intercept=trace cargo run --bin praxis_node

Common Issues

RabbitMQ connection failed

  • Verify RabbitMQ is running
  • Check credentials match
  • Ensure the PRAXIS_RABBITMQ_URL is correct

Database errors

  • Check file permissions for SQLite
  • Verify PostgreSQL is running and accessible
  • Check the connection URL format

Node not appearing

  • Verify the node connected to RabbitMQ
  • Check node logs for errors
  • Ensure service is running

Multiple Nodes

You can run multiple nodes locally (useful for testing):

# Terminal 1
cargo run --bin praxis_node

# Terminal 2
cargo run --bin praxis_node

Each node gets a unique ID and appears separately in the TUI.

Debugging

Enable debug logging

RUST_LOG=debug cargo run --bin praxis_service

Check RabbitMQ queues

Open http://localhost:15672 (praxis/praxis) to see queue activity.

Database Configuration

Praxis supports two database backends:

  • SQLite (default) - Zero-configuration, single-instance deployments
  • PostgreSQL - Production deployments, multiple service instances

Quick Reference

FeatureSQLitePostgreSQL
SetupAutomaticRequires server
Multiple instancesNoYes
Network storage (SMB/NFS)NoYes
Cloud deploymentsNoYes
Connection pooling1 connection10 connections
Best forLocal developmentProduction, cloud, teams

SQLite (Default)

No configuration required. The database file is created automatically at:

PlatformPath
Linux/macOS~/.praxis/operations.db
Windows%USERPROFILE%\.praxis\operations.db

SQLite is configured with WAL journal mode and a 5-second busy timeout.

Warning: SQLite does not work reliably on network file systems (SMB, NFS, Azure Files, EFS). File locking mechanisms don't translate correctly over these protocols, leading to database corruption and "database is locked" errors. For cloud deployments with persistent storage, use PostgreSQL.

Custom SQLite Path

export PRAXIS_DATABASE_URL=/path/to/custom.db
# or
export PRAXIS_DATABASE_URL=sqlite:///path/to/custom.db

PostgreSQL

Prerequisites

  1. PostgreSQL 14+ server
  2. A database created for Praxis
  3. User with CREATE TABLE privileges

Setup

Create the database:

createdb praxis

Configure the connection:

export PRAXIS_DATABASE_URL=postgresql://user:password@host:5432/praxis

The schema is created automatically on first run.

Connection URL Format

postgresql://[user[:password]@][host][:port]/database[?options]

Examples:

# Local server, default port
postgresql://praxis:secret@localhost/praxis

# Remote server with port
postgresql://praxis:secret@db.example.com:5432/praxis

# With SSL mode
postgresql://praxis:secret@db.example.com:5432/praxis?sslmode=require

SSL/TLS Configuration

For production deployments, enable SSL in the connection URL:

ModeDescription
sslmode=disableNo SSL (not recommended)
sslmode=preferTry SSL, fall back to unencrypted
sslmode=requireRequire SSL, don't verify certificate
sslmode=verify-caRequire SSL, verify CA
sslmode=verify-fullRequire SSL, verify CA and hostname

Example with full verification:

export PRAXIS_DATABASE_URL="postgresql://user:pass@host:5432/praxis?sslmode=verify-full&sslrootcert=/path/to/ca.crt"

Connection Pool Settings

PostgreSQL connections use these defaults:

SettingValueDescription
Max connections10Maximum pool size
Connect timeout10sTime to establish connection
Idle timeout600ssqlx library default; Praxis does not override this

Max connections and connect timeout are hardcoded by Praxis but sufficient for most deployments. For high-traffic scenarios, tune PostgreSQL server settings (max_connections, shared_buffers) instead.

Schema

The schema is created automatically. Key tables:

TablePurpose
operationsSemantic operation executions
operation_definitionsStored operation templates
intercepted_trafficCaptured HTTP traffic
intercept_rulesTraffic matching rules
traffic_matchesRule match results
operation_chainsChain workflow definitions
chain_executionsChain execution history
recon_resultsAgent reconnaissance data
event_logCentralized logging
service_configKey-value configuration
lua_agent_scriptsLua agent connector scripts

Traffic data is automatically pruned after 7 days.

Schema Migrations

Schema migrations run automatically on service startup. The service applies idempotent ALTER TABLE statements to add new columns introduced in newer versions. No manual migration steps are required when upgrading Praxis. The service_config table stores version tracking keys (e.g., builtin_scripts_version) to coordinate data migrations like updating built-in scripts.

Migration: SQLite to PostgreSQL

Praxis doesn't include a built-in migration tool. To migrate:

  1. Export data from SQLite:
sqlite3 ~/.praxis/operations.db .dump > praxis_dump.sql
  1. Convert SQLite-specific syntax to PostgreSQL:

    • INTEGER PRIMARY KEYBIGSERIAL PRIMARY KEY
    • BLOBBYTEA
    • Remove AUTOINCREMENT
    • Adjust date functions if used
  2. Import to PostgreSQL:

psql -d praxis -f praxis_dump.sql

For most deployments, starting fresh with PostgreSQL is simpler than migrating.

Multi-Instance and Cloud Deployments

PostgreSQL is required for:

  • Multiple praxis_service instances (e.g., behind a load balancer)
  • Cloud deployments (Azure Container Apps, AWS ECS, Kubernetes)
  • Any deployment using network-attached storage

SQLite limitations:

  • File locking doesn't work over SMB, NFS, Azure Files, or EFS
  • Concurrent writes from multiple processes cause corruption
  • "Database is locked" errors under load
  • No recovery from partial writes on network storage

PostgreSQL handles:

  • Concurrent connections from multiple instances
  • Proper transaction isolation and row-level locking
  • Network-transparent client/server architecture
  • Connection pooling per instance

Backup and Restore

SQLite

SQLite runs in WAL mode, so recent writes can live only in the operations.db-wal/-shm sidecar files. A plain cp of operations.db while the service is running risks an inconsistent backup. Use the SQLite online backup command instead, which is safe while the service is up:

# Backup (safe while the service is running)
sqlite3 ~/.praxis/operations.db ".backup ~/.praxis/operations.db.backup"

# Restore
cp ~/.praxis/operations.db.backup ~/.praxis/operations.db

If you copy files manually instead, stop the service first and copy all three files together (operations.db, operations.db-wal, operations.db-shm).

PostgreSQL

# Backup
pg_dump -Fc praxis > praxis_backup.dump

# Restore
pg_restore -d praxis praxis_backup.dump

For point-in-time recovery, configure PostgreSQL WAL archiving.

Troubleshooting

Connection Refused

Error: Connection refused (os error 111)
  • Verify PostgreSQL is running: pg_isready -h host -p 5432
  • Check firewall rules allow port 5432
  • Verify pg_hba.conf allows connections from your IP

Authentication Failed

Error: password authentication failed for user "praxis"
  • Verify username and password in URL
  • Check pg_hba.conf authentication method
  • Ensure user exists: \du in psql

Database Does Not Exist

Error: database "praxis" does not exist

Create it:

createdb praxis
# or
psql -c "CREATE DATABASE praxis;"

SSL Required

Error: SSL connection is required

Add SSL mode to connection URL:

postgresql://user:pass@host:5432/praxis?sslmode=require

SQLite Locked

Error: database is locked
  • If using network storage (SMB, NFS, Azure Files): switch to PostgreSQL
  • Only one praxis_service instance can use SQLite
  • Close other connections (GUI tools, scripts)
  • Check for zombie processes: lsof ~/.praxis/operations.db

Performance Tuning

PostgreSQL Server

For production workloads, tune these PostgreSQL settings:

# postgresql.conf
max_connections = 100
shared_buffers = 256MB
effective_cache_size = 768MB
maintenance_work_mem = 64MB
checkpoint_completion_target = 0.9
wal_buffers = 16MB
default_statistics_target = 100
random_page_cost = 1.1
effective_io_concurrency = 200
work_mem = 4MB

Vacuum and Maintenance

PostgreSQL autovacuum handles routine maintenance. For large traffic volumes, consider:

# Manual vacuum after bulk deletes
psql -d praxis -c "VACUUM ANALYZE intercepted_traffic;"

Indexing

The schema includes indexes for common queries. If you run custom queries against the database, add indexes as needed:

-- Example: index for custom report queries
CREATE INDEX idx_operations_agent ON operations(agent_short_name);

Azure Deployment

This guide covers deploying Praxis to Azure using Azure Container Apps with PostgreSQL, automatic scaling, and persistent storage.

Architecture

┌─────────────────────────────────────────────────┐
│                      Azure                      │
│                                                 │
│  ┌──────────────┐    ┌──────────────────────┐   │
│  │ Container    │    │  Container Instance  │   │
│  │ App (Praxis) │◄───│  (RabbitMQ)          │   │
│  └──────┬───────┘    └──────────────────────┘   │
│         │                       │               │
│  ┌──────▼───────┐     ┌─────────▼────────┐      │
│  │  PostgreSQL  │     │  Azure File Share│      │
│  │  Flexible    │     │  (persistence)   │      │
│  └──────────────┘     └──────────────────┘      │
│                                                 │
└─────────────────────────────────────────────────┘
            │
            │ Internet
            │
      ┌─────▼─────┐
      │   Nodes   │
      │ (Targets) │
      └───────────┘

Prerequisites

  1. Azure CLI - Install from https://docs.microsoft.com/en-us/cli/azure/install-azure-cli
  2. Docker - Install from https://docs.docker.com/get-docker/
  3. Azure Subscription - Active subscription with appropriate permissions

Quick Start

1. Login to Azure

az login
az account set --subscription <your-subscription-id>

2. Deploy Praxis

cd /path/to/praxis
./scripts/azure-deploy.sh

The script will:

  • Create all required Azure resources
  • Build and push Docker images to ACR
  • Deploy PostgreSQL Flexible Server
  • Deploy Praxis with RabbitMQ
  • Display connection details

3. Access Your Deployment

After deployment completes, you'll receive URLs for:

  • RabbitMQ (AMQP): amqp://praxis:praxis@praxis-rabbitmq-{hash}.{region}.azurecontainer.io:5672 — point your praxis TUI at this with praxis set-rabbitmqurl <url> or PRAXIS_RABBITMQ_URL
  • RabbitMQ Management UI: http://praxis-rabbitmq-{hash}.{region}.azurecontainer.io:15672

Script Commands

./scripts/azure-deploy.sh            # Deploy Praxis
./scripts/azure-deploy.sh --stop     # Stop all resources (pause billing)
./scripts/azure-deploy.sh --start    # Start all resources
./scripts/azure-deploy.sh --delete   # Delete all Azure resources
./scripts/azure-deploy.sh --help     # Show help

Configuration

Customize deployment with environment variables:

export AZURE_RESOURCE_GROUP="praxis-rg"
export AZURE_LOCATION="westus2"
export PRAXIS_POSTGRES_PASS="MySecureP@ssword123"

./scripts/azure-deploy.sh
VariableDefaultDescription
AZURE_RESOURCE_GROUPpraxis-rgResource group name
AZURE_LOCATIONfrancecentralAzure region
AZURE_ACR_NAMEpraxisacrContainer registry name prefix
AZURE_CONTAINER_APP_ENVpraxis-envContainer app environment
AZURE_STORAGE_ACCOUNTpraxisstorageStorage account prefix
AZURE_POSTGRES_SERVERpraxis-postgresPostgreSQL server name prefix
PRAXIS_POSTGRES_PASSPraxis_db_2024!PostgreSQL admin password

Resource names are automatically made unique using a hash suffix derived from your subscription and resource group.

What Gets Deployed

  1. Azure Container Registry (ACR) - Stores Praxis and RabbitMQ images
  2. Azure Storage Account - File share for RabbitMQ persistence
  3. PostgreSQL Flexible Server - Database backend (Burstable B1ms tier)
  4. Container App Environment - Managed environment for Container Apps
  5. RabbitMQ - Azure Container Instance with persistent storage
  6. Praxis - Container App (no external ingress is configured by the script)

Stopping and Starting

To pause billing when not using Praxis:

# Stop all resources
./scripts/azure-deploy.sh --stop

This will:

  • Stop PostgreSQL Flexible Server
  • Stop RabbitMQ Container Instance
  • Scale Praxis Container App to 0 replicas

To resume:

# Start all resources
./scripts/azure-deploy.sh --start

Storage accounts and Container Registry may still incur minimal charges when stopped.

Updating Deployments

After making code changes, redeploy by running the script again:

./scripts/azure-deploy.sh

The script detects existing resources and updates them rather than recreating.

Management Commands

# View Praxis logs (real-time)
az containerapp logs show -n praxis-app -g praxis-rg --follow

# View RabbitMQ logs
az container logs --name praxis-rabbitmq -g praxis-rg --follow

# Restart RabbitMQ
az container restart --name praxis-rabbitmq -g praxis-rg

Troubleshooting

# Check Praxis app status
az containerapp show -n praxis-app -g praxis-rg --query properties.runningStatus

# View recent logs
az containerapp logs show -n praxis-app -g praxis-rg --tail 100
az container logs --name praxis-rabbitmq -g praxis-rg --tail 100

# Check RabbitMQ status
az container show --name praxis-rabbitmq -g praxis-rg --query instanceView.state

# Check PostgreSQL status
az postgres flexible-server show -n <server-name> -g praxis-rg --query state

Security Best Practices

Warning: Praxis has no built-in authentication or access control. Anyone who can reach the RabbitMQ endpoint can drive the deployment. You must protect access at the network or transport level.

Protecting the RabbitMQ Endpoint

  • VNet Integration — keep RabbitMQ on an internal network and reach it via VPN or Azure Bastion.
  • IP Allowlisting — restrict the RabbitMQ Container Instance's public IP to known operator IPs.
  • TLS / strong credentials — change the default praxis:praxis credentials and front RabbitMQ with TLS.

Other Security Recommendations

  1. Change default passwords - Set PRAXIS_POSTGRES_PASS for PostgreSQL. RabbitMQ credentials (praxis/praxis) are hardcoded in the deploy script with no environment-variable override, so changing them currently requires editing scripts/azure-deploy.sh directly
  2. Use Azure Key Vault - Store secrets securely rather than in environment variables
  3. Enable diagnostic logging - Send logs to Log Analytics for audit trails
  4. Regular updates - Keep base images current

Cleanup

Delete all resources:

./scripts/azure-deploy.sh --delete

This deletes:

  • Container Instance (RabbitMQ)
  • Container App (Praxis)
  • PostgreSQL Flexible Server
  • Azure Container Registry
  • Storage Account
  • Log Analytics Workspace
  • Container App Environment
  • Resource Group

Verify deletion:

az group list --query "[?name=='praxis-rg']" -o table

Contributing

Praxis is open source and welcomes contributions. This guide covers the codebase structure and how to get involved.

Repository Structure

praxis/
├── common/              # Shared types and utilities
├── node/                # Node component (runs on targets)
├── service/             # Service component (backend)
├── cli/                 # CLI / TUI (first-party client)
├── semantic_parser/     # LLM-based text parsing library
├── docs/                # This documentation
├── .github/             # CI/CD workflows
└── docker-compose.yml   # Local development setup

Components

Common (common/)

Shared code used by all components:

  • Message types and serialization
  • RabbitMQ utilities
  • AI client abstraction
  • Logging macros

When adding functionality needed by multiple components, put it here.

Node (node/)

The agent that runs on target machines:

  • Agent connectors (Claude Code, Gemini, etc.)
  • Traffic interception
  • Session management
  • Terminal handling
node/src/
├── agent_connectors/    # Per-agent implementations
│   └── lua/             # Lua connector runtime + CDP helpers
├── intercept/           # Traffic interception
├── terminal/            # PTY terminal
└── runtime.rs           # Main event loop

Lua-based agent scripts (Agy/Antigravity, Claude Code, Claude Desktop, Codex, Cursor, Droid, Gemini, M365 Copilot, Pi) live in agents/ at the project root and are embedded into the binary at build time.

Service (service/)

The backend that coordinates everything:

  • Node tracking
  • Semantic operations
  • Chain execution
  • Database persistence
service/src/
├── semantic_ops/        # Operation execution
├── chain_execution/     # Chain runner
├── database/            # Persistence layer
└── config/              # Service configuration

CLI (cli/)

The first-party Praxis client:

  • Interactive terminal UI (Ratatui)
  • Non-interactive subcommands for scripting
  • ACP bridge mode (stdin/stdout) for external tooling
  • RabbitMQ-based connection to the service
cli/
└── src/
    ├── app/             # TUI windows (orchestrator, nodes, intercept, ...)
    ├── components/      # Shared widgets
    └── main.rs          # Entry point

Semantic Parser (semantic_parser/)

Standalone library for LLM-based parsing:

  • Schema-based extraction
  • Multi-provider support
  • Retry logic

See Semantic Parser for details.

Development Workflow

Setup

  1. Install Rust
  2. Start RabbitMQ: docker compose up rabbitmq
  3. Build: cargo build
  4. Run service: cargo run --bin praxis_service
  5. Run node: cargo run --bin praxis_node
  6. Run TUI: cargo run --bin praxis_cli

Environment Variables for Development

VariableDefault (debug)Description
PRAXIS_IGNORE_SERVICE_AGENTS1When 1, node ignores Lua scripts pushed from the service and uses only embedded scripts. Set to 0 to test service-managed agent scripts.
PRAXIS_DATABASE_URLSQLite in home dirDatabase connection string
PRAXIS_RABBITMQ_URLamqp://praxis:praxis@localhost:5672RabbitMQ connection

Making Changes

  1. Create a branch
  2. Make changes
  3. Run tests: cargo test
  4. Build: cargo build
  5. Test manually
  6. Submit PR

Code Style

  • Follow existing patterns
  • Use common::log_* macros for logging (except in node/src/runtime.rs event forwarder-use tracing::* there to avoid recursion)
  • Prefer explicit over clever
  • Comment non-obvious blocks

Adding Agent Connectors

See Adding New Connectors. Prefer Lua-based connectors for CLI agents — they can be developed and tested at runtime via the TUI's Settings → Agents tab without recompiling.

Lua agent scripts live in agents/ at the project root and are embedded into binaries at build time. Shared libraries are at node/src/agent_connectors/lua/lib/ (helpers.lua for common utilities, devtools.lua for CDP/DevTools support, uiautomation.lua for Windows UI Automation).

Adding Operations

Operations are JSON definitions. Add to the library via the TUI's Operations window (Ctrl+P) or directly to the database.

Testing

Unit Tests

cargo test

Integration Tests

Run the full stack and test manually. Automated integration tests are on the roadmap.

Testing Connectors

  1. Install the target agent
  2. Run a node
  3. Verify fingerprinting
  4. Test session creation
  5. Test interception

Pull Requests

Before Submitting

  • Code builds without warnings
  • Tests pass
  • Changes are documented
  • Commit messages are clear

PR Process

  1. Open a PR against prerelease
  2. Describe the change
  3. Wait for review
  4. Address feedback
  5. Merge when approved

Feature Requests

Open an issue with:

  • What you want
  • Why it's useful
  • Any implementation ideas

Bug Reports

Open an issue with:

  • What happened
  • What you expected
  • Steps to reproduce
  • Logs if available

Contact

Semantic Parser

The semantic parser is a standalone library for extracting structured data from unstructured text using LLMs. It's used throughout Praxis for various parsing tasks.

What It Does

Given:

  • Raw text (config files, transcripts, logs)
  • A JSON schema
  • Parsing instructions

The semantic parser returns structured JSON matching the schema.

Usage in Praxis

Semantic Recon

When running semantic reconnaissance, the parser extracts tool definitions from config files:

Input: Claude Code mcp.json file contents
Schema: { "tools": [{ "name": string, "description": string }] }
Output: Structured tool list

Library API

Basic Usage

#![allow(unused)]
fn main() {
use semantic_parser::{SemanticParser, ParserConfig, Provider};

// Configure the parser
let config = ParserConfig {
    provider: Provider::Anthropic,
    api_key: "sk-...".to_string(),
    model: "claude-haiku-4-5-20241022".to_string(),
    max_retries: 3,
    max_tokens: Some(4096),
    base_url: None,
};

// Create parser
let parser = SemanticParser::new(config)?;

// Parse text
let schema = r#"{"name": "string", "version": "string"}"#;
let prompt = "Extract the package name and version";
let text = "This is mypackage version 1.2.3";

let result = parser.parse(text, prompt, schema).await?;
// Returns: {"name": "mypackage", "version": "1.2.3"}
}

Provider Support

The parser supports multiple LLM providers:

ProviderIDNotes
AnthropicanthropicClaude models
OpenAIopenaiGPT models
GooglegeminiGemini models
GroqgroqFast inference
CerebrascerebrasFast inference
MistralmistralMistral models
xAIxaiGrok models
NVIDIAnvidiaNIM models
MiniMaxminimaxMiniMax models
Moonshot AImoonshotMoonshot models
Fireworks AIfireworksaiFireworks-hosted models
OpenRouteropenrouterMulti-provider routing
OllamaollamaLocal models
CustomcustomAny OpenAI-compatible endpoint

Model Selection

For parsing tasks, use fast, cheap models:

Recommended:

  • claude-haiku-4-5-20241022 (Anthropic)
  • gpt-4o-mini (OpenAI)
  • gemini-1.5-flash (Google)
  • llama-3.3-70b-versatile (Groq)

Fast inference providers like Groq and Cerebras work well since parsing typically requires many sequential calls.

Schema Format

Schemas are JSON Schema-like strings:

{
  "tools": [
    {
      "name": "string",
      "description": "string",
      "parameters": {}
    }
  ],
  "config_path": "string"
}

The parser attempts to return valid JSON matching this structure.

Retry Logic

The parser includes built-in retry logic:

  1. Send request to LLM
  2. Parse response as JSON
  3. If invalid, retry with the identical prompt (the previous error is logged but not fed back into the next attempt - retries are blind repeats, not feedback-guided)
  4. Return result or error after max retries

Default: 3 retries.

Error Handling

The parser returns Result<String>:

  • Success: Valid JSON string
  • Error: Parsing failed after retries, or API error
#![allow(unused)]
fn main() {
match parser.parse(text, prompt, schema).await {
    Ok(json) => process_result(&json),
    Err(e) => log::warn!("Parsing failed: {}", e),
}
}

Non-Throwing Alternative

try_parse offers the same parsing behavior without returning a Result. It always returns a ParseResult struct instead of an error:

#![allow(unused)]
fn main() {
pub struct ParseResult {
    pub success: bool,
    pub json: Option<String>,
    pub error: Option<String>,
}

let result = parser.try_parse(text, prompt, schema).await;
if result.success {
    process_result(&result.json.unwrap());
} else {
    log::warn!("Parsing failed: {}", result.error.unwrap_or_default());
}
}

Configuration in Praxis

The semantic parser LLM is configured in Settings:

  1. Open Settings (Ctrl+S) → LLM
  2. Configure Semantic Parser provider and model
  3. Save

This configures only the Semantic Parser feature slot. Praxis has 5 independently configurable LLM feature slots (Semantic Parser, Traffic Parser, Semantic Ops, Orchestrator, Doc Helper), each set separately in Settings - the Semantic Parser slot governs only its own request handler, not the other LLM-backed features.

Performance Considerations

Latency: Each parse call makes an LLM request. For bulk parsing, consider batching.

Cost: Fast models are cheaper. Choose based on parsing complexity.

Accuracy: More capable models produce better results for complex extractions.

Examples

Parse MCP Config

#![allow(unused)]
fn main() {
let schema = r#"{
  "servers": [{
    "name": "string",
    "command": "string",
    "args": ["string"],
    "env": {}
  }]
}"#;

let result = parser.parse(
    &mcp_json_contents,
    "Extract all MCP server configurations",
    schema
).await?;
}

Parse Session Transcript

#![allow(unused)]
fn main() {
let schema = r#"{
  "files_accessed": ["string"],
  "commands_run": ["string"],
  "api_keys_mentioned": ["string"]
}"#;

let result = parser.parse(
    &transcript,
    "Extract file paths, commands, and any API keys from this conversation",
    schema
).await?;
}

Parse Traffic

#![allow(unused)]
fn main() {
let schema = r#"{
  "model": "string",
  "prompt_preview": "string",
  "token_count": "number",
  "has_tool_calls": "boolean"
}"#;

let result = parser.parse(
    &request_body,
    "Extract LLM request metadata",
    schema
).await?;
}

Standalone Use

The semantic parser can be used outside of Praxis:

[dependencies]
semantic_parser = { path = "../semantic_parser" }

It's designed to be a general-purpose LLM parsing library. Note that semantic_parser has a mandatory path dependency on Praxis's internal common (praxis_common) crate, so copying just the semantic_parser directory into an unrelated project won't build on its own - common needs to come with it.

API Reference

This reference documents the message types and RabbitMQ queues/exchanges used for communication between Praxis components.

RabbitMQ Queues

QueueDirectionPurpose
NodeSignalNode → ServiceNode registration, commands, traffic
NodeBroadcastService → All NodesBroadcast commands to all nodes (fanout exchange)
Node_{id}Service → NodeCommands for specific node
Node_{id}_semanticService → NodeSemantic parser responses
Node_{id}_resetService → NodeReset/Shutdown lifecycle signals (dedicated queue, never blocked by command handlers)
ClientSignalClient → ServiceClient requests
ClientBroadcastService → All ClientsSystem state updates (fanout exchange)
Client_{id}Service → ClientResponses for specific client
NodeEventLogNode → ServiceApplication log entries
WebEventLogLegacy client → ServiceCompatibility queue retained for legacy producers
ServiceEventLogService → ServiceService log entries

Named classic queues above are declared durable. Fanout broadcast consumers use exclusive auto-delete server-named queues bound to NodeBroadcast / ClientBroadcast.

Message Flow

┌────────┐                  ┌─────────┐                  ┌────────┐
│ Client │                  │ Service │                  │  Node  │
└───┬────┘                  └────┬────┘                  └───┬────┘
    │                            │                           │
    │──ClientSignal─────────────▶│                           │
    │                            │──Node_{id}───────────────▶│
    │                            │                           │
    │                            │◀──────────NodeSignal──────│
    │◀──Client_{id}──────────────│                           │
    │                            │                           │
    │◀──ClientBroadcast exchange─│──NodeBroadcast exchange─▶│
    │                            │                           │

Node Messages

NodeSignalMessage

Messages sent from nodes to the service via NodeSignal queue.

#![allow(unused)]
fn main() {
pub enum NodeSignalMessage {
    // Node registration on startup
    Registration(NodeRegistration),

    // Periodic information update
    InformationUpdate(NodeInformationUpdate),

    // Response to a command
    CommandResponse(CommandResponse),

    // PTY terminal output
    TerminalOutput(TerminalOutput),

    // Request semantic parsing from service
    SemanticParserRequest { node_id: String, request: SemanticParserRequest },

    // Intercepted traffic entry
    InterceptedTraffic(InterceptedTrafficEntry),

    // Intercept status update
    InterceptStatusUpdate(InterceptStatus),

    // Outbound ACP frame (response or session/update notification)
    Acp { node_id: String, client_id: String, json_rpc: String },
}
}

NodeDirectMessage

Messages sent to specific nodes via Node_{id} queue.

#![allow(unused)]
fn main() {
pub enum NodeDirectMessage {
    // Registration acknowledgment
    RegistrationAck(NodeRegistrationAck),

    // Command to execute
    Command(CommandRequest),

    // Semantic parser response
    SemanticParserResponse(SemanticParserResponse),

    // Reset the node: cancel all operations, tear down state, re-register.
    // Delivered on the dedicated `Node_{id}_reset` queue so it is never
    // blocked by in-flight command handlers.
    Reset,

    // Gracefully stop the node: cancel all operations, restore system
    // state, and exit without reconnecting. Also delivered on
    // `Node_{id}_reset`.
    Shutdown,

    // Inbound ACP frame (request or notification destined for the node)
    Acp(AcpFrame),
}
}

NodeBroadcastMessage

Messages broadcast to all nodes via NodeBroadcast fanout exchange.

#![allow(unused)]
fn main() {
pub enum NodeBroadcastMessage {
    // Request all nodes to send information update
    NodeInformationUpdateRequest,

    // Request nodes to re-register
    NodeRefreshRegistration,

    // Enable/disable centralized event logging
    EventLoggingSet { enabled: bool },

    // Enable/disable the native Praxis agent connector, pushing the
    // resolved per-session config (provider/api key/model/etc.) when enabled
    PraxisAgentEnabled { enabled: bool, config: Option<PraxisAgentConfig> },

    // Atomic agent registry update: rebuild the registry from native agents
    // plus these Lua connector scripts
    AgentRegistryUpdate { scripts: Vec<String> },

    // Replace the node's current intercept target list (disabled targets
    // are filtered out before broadcast)
    InterceptTargetsUpdate { targets: Vec<InterceptTargetConfig> },
}
}

Client Messages

ClientSignalMessage

Messages sent from clients to the service via ClientSignal queue.

#![allow(unused)]
fn main() {
pub enum ClientSignalMessage {
    // Registration
    Registration(ClientRegistration),

    // Command to forward to node
    Command(CommandRequest),

    // Remove a node from tracking
    RemoveNode { node_id: String },

    // Reset a node (cancel operations, tear down state, re-register)
    ResetNode { node_id: String },

    // Remote (virtual) nodes - synthetic node entries backed by a bridge
    // to an external agent server (e.g. a Codex app-server over WebSocket).
    // `kind` selects the bridge implementation.
    AddRemoteNode { kind: String, url: String, token: Option<String> },

    // Documentation helper agent - a lightweight, doc-seeded conversational
    // assistant, independent of the orchestrator. Each prompt carries its
    // own `request_id` for correlation; responses stream back as the
    // `DocHelper*` direct messages.
    DocHelperPrompt {
        client_id: String,
        request_id: String,
        prompt: String,
        history: Vec<(String, String)>,
        context: Option<String>,
    },
    DocHelperCancel { client_id: String, request_id: String },

    // Semantic Operations
    SemanticOpRun { client_id, node_id, agent_short_name, operation_name, request_id },
    SemanticOpCancel { operation_id },
    SemanticOpRemove { operation_id },
    SemanticOpClear,
    SemanticOpListRequest,

    // Service Configuration
    ServiceConfigGet { client_id, keys: Vec<String> },
    ServiceConfigSet { client_id, values: HashMap<String, String> },

    // Operation Definitions
    OpDefAdd { client_id, content: String },
    OpDefList { client_id },
    OpDefDelete { client_id, full_name },
    // Set the disabled flag on an operation definition
    OpDefSetDisabled { client_id, full_name, disabled: bool },
    OpDefGet { client_id, full_name },

    // Chain Definitions
    ChainDefList { client_id },
    ChainGet { client_id, chain_id },
    ChainCreate { client_id, definition: ChainDefinitionInput },
    ChainUpdate { client_id, chain_id, definition: ChainDefinitionInput },
    ChainDelete { client_id, chain_id },
    // Set the disabled flag on a chain
    ChainSetDisabled { client_id, chain_id, disabled: bool },
    ChainRun { client_id, chain_id, node_id, agent_short_name, working_dir, target_spec },
    ChainCancel { client_id, execution_id },
    ChainExecutionList { client_id },
    ChainExecutionRemove { execution_id },
    ChainExecutionClear,

    // Chain Triggers
    ChainTriggerCreate { client_id, chain_id, trigger_config: TriggerConfig, target_spec: TargetSpec },
    ChainTriggerUpdate { client_id, trigger_id, enabled, trigger_config, target_spec },
    ChainTriggerDelete { client_id, trigger_id },
    ChainTriggerList { client_id, chain_id: Option<String> },

    // Traffic Interception (request_id is client-generated; echoed in responses;
    // Serde default empty string if omitted by older peers — prefer atomic upgrades)
    TrafficLogRequest { client_id, request_id, filters: TrafficLogFilters },
    TrafficMatchesRequest { client_id, request_id, rule_id, limit, offset },
    TrafficClear { client_id, request_id },
    TrafficGetRequest { client_id, request_id, id },
    TrafficSearchRequest { client_id, request_id, filters: TrafficSearchFilters },
    InterceptRuleCreate { client_id, name, regex_pattern, ... },
    InterceptRuleUpdate { ... },
    InterceptRuleDelete { client_id, id },
    InterceptRuleList { client_id },
    InterceptEnable { client_id, node_id, method },
    InterceptDisable { client_id, node_id },

    // Application Log
    ApplicationLogRequest { client_id, node_id, level_filter, regex_filter, limit, offset },
    ApplicationLogClear { client_id, node_id },

    // Recon
    ReconGet { client_id, node_id, agent_short_name },

    // Toolkit
    ToolkitList { client_id },
    ToolkitRecon { client_id, tool_name, target_spec: TargetSpec },
    ToolkitExecute { client_id, tool_name, target_spec: TargetSpec, params: serde_json::Value },
    ToolkitApply { client_id, tool_name, execution_id, targets: Vec<ToolkitApplyItem> },

    // Payloads (static content for Payload chain elements)
    PayloadList { client_id },
    PayloadUpsert { client_id, id: Option<String>, shortname, content },
    PayloadDelete { client_id, id },

    // Lua agent scripts (stored in the service database)
    LuaAgentScriptAdd { client_id, name, script },
    LuaAgentScriptDelete { client_id, script_id },
    LuaAgentScriptList { client_id },
    LuaAgentScriptUpdate { client_id, script_id, name, script },
    LuaAgentScriptResetDefaults { client_id },
    LuaAgentScriptToggleDisabled { client_id, script_id, disabled: bool },

    // Intercept targets virtual file (TOML text stored in service_config,
    // parsed into an InterceptTargetConfig list and pushed to nodes on change)
    InterceptTargetsGet { client_id },
    InterceptTargetsSet { client_id, text: String },
    InterceptTargetsResetDefaults { client_id },

    // LogQuery - KQL query interface over captured logs
    LogQuery { client_id, query: String },

    // Orchestrator - ACP JSON-RPC message from client to service
    AcpMessage { client_id, json_rpc: String },

    // AgentChat - IRC-style multi-agent chat
    AgentChatStart { client_id, goal: Option<String>, yolo_mode: bool },
    AgentChatStop { client_id, session_id },
    AgentChatAddAgent { client_id, session_id, node_id, agent_short_name },
    AgentChatRemoveAgent { client_id, session_id, agent_id },
    AgentChatReorderAgents { client_id, session_id, agent_ids: Vec<String> },
    AgentChatSendMessage { client_id, session_id, content, channel_id: Option<String>, recipient_nickname: Option<String> },
    AgentChatJoinChannel { client_id, session_id, channel_name },
    AgentChatGetHistory { client_id, session_id, channel_id: Option<String>, limit: u32 },
    AgentChatGetState { client_id, session_id: Option<String> },
}
}

ClientDirectMessage

Messages sent to specific clients via Client_{id} queue.

#![allow(unused)]
fn main() {
pub enum ClientDirectMessage {
    // Registration
    RegistrationAck(ClientRegistrationAck),
    CommandResponse(CommandResponse),
    StateUpdate(SystemState),
    TerminalOutput(TerminalOutput),

    // Documentation helper agent streaming responses, correlated by
    // request_id. Chunk carries an incremental text delta; FollowUp
    // indicates the helper is consulting documentation before producing a
    // detailed continuation; Complete signals the turn finished (naturally
    // or via cancellation); Error reports a failure.
    DocHelperChunk { request_id: String, delta: String },
    DocHelperFollowUp { request_id: String },
    DocHelperComplete { request_id: String },
    DocHelperError { request_id: String, message: String },

    // Semantic Operations
    SemanticOpQueued { operation_id, queue_position, request_id },
    SemanticOpUpdate(SemanticOpUpdate),
    SemanticOpList(Vec<SemanticOpUpdate>),

    // Service Configuration
    ServiceConfigResponse { values: HashMap<String, String> },
    ServiceConfigSaved,

    // Operation Definitions
    OpDefListResponse { definitions: Vec<OperationDefinitionInfo> },
    OpDefGetResponse { definition: Option<OperationDefinitionInfo> },
    OpDefAdded { full_name },
    OpDefDeleted { full_name, success },
    OpDefError { message },

    // Chain Definitions
    ChainDefListResponse { chains: Vec<ChainDefinitionInfo> },
    ChainGetResponse { chain: Option<ChainDefinitionFull> },
    ChainCreated { chain: ChainDefinitionInfo },
    ChainUpdated { chain: ChainDefinitionInfo },
    ChainDeleted { chain_id, success },
    ChainError { message },
    ChainExecutionStarted { execution_id, chain_id },
    ChainExecutionUpdate(ChainExecutionUpdate),
    ChainExecutionListResponse { executions: Vec<ChainExecutionUpdate> },

    // Chain Triggers
    ChainTriggerCreated { trigger: ChainTriggerInfo },
    ChainTriggerUpdated { trigger: ChainTriggerInfo },
    ChainTriggerDeleted { trigger_id: String },
    ChainTriggerListResponse { triggers: Vec<ChainTriggerInfo> },

    // Traffic Interception (optional error: queue/DB failures; request_id correlates)
    TrafficLogResponse { request_id, entries, total_count, error: Option<String> },
    TrafficSearchResponse { request_id, entries, total_count, error: Option<String> },
    TrafficMatchesResponse { request_id, matches, total_count, error: Option<String> },
    // generation = clear-epoch after wipe; service_instance_id scopes it to one service process
    TrafficCleared {
        request_id,
        deleted_count,
        generation,
        service_instance_id,
        error: Option<String>,
    },
    TrafficGetResponse { request_id, id, entry: Option<InterceptedTrafficEntry>, error: Option<String> },
    InterceptRuleListResponse { rules: Vec<InterceptRule> },
    InterceptRuleCreated { rule },
    InterceptRuleUpdated { rule },
    InterceptRuleDeleted { id, success },
    InterceptRuleError { message },
    InterceptStatusUpdate(InterceptStatus),
    // request_id correlates the awaiting client toggle; status may include cleanup_required
    InterceptCommandResult {
        request_id,
        node_id,
        error: Option<String>,
        status: Option<InterceptStatus>,
    },

    // Application Log
    ApplicationLogResponse { node_id, entries, total_count },
    ApplicationLogCleared { deleted_count },

    // Recon
    ReconGetResponse { node_id, agent_short_name, recon_result, performed_at, is_semantic },

    // Toolkit
    ToolkitListResponse { tools: Vec<ToolkitToolInfo>, models: Vec<ToolkitModelOption> },
    ToolkitReconResponse { tool_name, targets: Vec<ToolkitReconTarget> },
    ToolkitExecutionResult { result: ToolkitExecuteResult },
    ToolkitApplyResult { execution_id, results: Vec<ToolkitApplyOutcome> },
    ToolkitExecutionProgress { execution_id, current: usize, total: usize },
    ToolkitError { message },

    // Payloads
    PayloadListResponse { payloads: Vec<PayloadInfo> },
    PayloadUpserted { payload: PayloadInfo },
    PayloadDeleted { id, success },
    PayloadError { message },

    // Lua agent scripts
    LuaAgentScriptAdded { id, name },
    LuaAgentScriptDeleted { script_id, success },
    LuaAgentScriptListResponse { scripts: Vec<LuaAgentScriptInfo> },
    LuaAgentScriptUpdated { id, name },
    LuaAgentScriptDefaultsReset { count: usize },
    LuaAgentScriptDisabledToggled { script_id, disabled: bool },

    // Intercept targets virtual file. `text` is the current raw file
    // contents; `targets` is the parsed list (empty when `error` is set).
    InterceptTargetsState { text: String, targets: Vec<InterceptTargetConfig>, error: Option<String> },

    // LogQuery
    LogQueryResponse { columns: Vec<String>, rows: Vec<Vec<serde_json::Value>>, total_count: usize },
    LogQueryError { message },

    // Orchestrator - ACP JSON-RPC message from service to client
    AcpMessage { json_rpc: String },

    // AgentChat responses
    AgentChatSessionStarted { session_id, goal: Option<String> },
    AgentChatSessionStopped { session_id },
    AgentChatAgentAdded { session_id, agent: AgentChatAgentInfo },
    AgentChatAgentRemoved { session_id, agent_id },
    AgentChatAgentStatusChanged { session_id, agent_id, status: AgentChatAgentStatus },
    AgentChatChannelCreated { session_id, channel: AgentChatChannelInfo },
    AgentChatChannelUpdated { session_id, channel: AgentChatChannelInfo },
    AgentChatAgentJoinedChannel { session_id, agent_id, channel_id },
    AgentChatAgentLeftChannel { session_id, agent_id, channel_id },
    AgentChatMessage { session_id, message: AgentChatMessageInfo },
    AgentChatStateUpdate { session: AgentChatSessionState },
    AgentChatHistoryResponse { session_id, channel_id: Option<String>, messages: Vec<AgentChatMessageInfo> },
    AgentChatError { message },
}
}

ClientBroadcastMessage

Messages broadcast to all clients via ClientBroadcast fanout exchange.

#![allow(unused)]
fn main() {
pub enum ClientBroadcastMessage {
    // Periodic state update with all nodes
    StateUpdate(SystemState),

    // Service process started/restarted. Clients must re-register against
    // service_instance_id; RegistrationAck (with registration_nonce) is the
    // only control-plane rebind of clear-generation identity. Live
    // traffic/match batches never rebind.
    ServiceOnline { service_instance_id },

    // Chain execution progress
    ChainExecutionUpdate(ChainExecutionUpdate),

    // Semantic operation progress
    SemanticOpUpdate(SemanticOpUpdate),

    // Intercept status (enabled / method / cleanup_required)
    InterceptStatusUpdate(InterceptStatus),

    // Enable/disable centralized event logging
    EventLoggingSet { enabled: bool },

    // Live intercept streams (bodies stripped). generation is the service
    // clear-epoch; service_instance_id must match the client's bound instance
    // or the batch is dropped (no ABA rebind from data plane).
    InterceptedTrafficBatch { entries, generation, service_instance_id },
    TrafficMatchBatch { matches, generation, service_instance_id },
}
}

RegistrationAck includes service_instance_id (UUID per service process). Clients adopt it only on registration / re-registration after ServiceOnline.

InterceptStatus fields: node_id, enabled, method, proxy_port, intercepted_domains, and cleanup_required (default false). When cleanup_required is true, enable is blocked until Disable/Reset cleanup succeeds.

Node Protocol

Agent and session interaction with the node uses ACP (Agent Client Protocol) over RabbitMQ. Everything else uses the NodeCommand envelope.

ACP transport envelope

#![allow(unused)]
fn main() {
pub struct AcpFrame {
    pub client_id: String,   // originating/receiving external client
    pub json_rpc: String,    // raw JSON-RPC 2.0 frame
}
}

NodeDirectMessage::Acp(AcpFrame) carries inbound frames (service → node). NodeSignalMessage::Acp { node_id, client_id, json_rpc } carries outbound frames (node → service → originating client).

The service proxies node-bound ACP frames: an external client's frame is forwarded to the right node when _meta.praxis.nodeId is set on session/new, and subsequent frames for the returned session_id are routed automatically. Inside the service, orchestrator-originated frames use a svc_* pseudo-client-id so responses are consumed in-process by AcpNodeProxy::request instead of being fanned out to a RabbitMQ client queue.

Connector selection

session/new requires a _meta.praxis.connector field naming the local agent connector to use (e.g. "claudecode", "codex"). Discover the connector catalog via InitializeResponse._meta.connectors:

{
  "extensions": { "_praxis/recon": { "version": 1 } },
  "connectors": [
    { "shortName": "claudecode", "name": "Claude Code" },
    { "shortName": "codex",       "name": "OpenAI Codex" }
  ],
  "nodeId": "..."
}

Extension methods

All extensions are advertised under InitializeResponse._meta.extensions.

  • _praxis/recon — agent-scoped reconnaissance. Params { "agent_short_name": string, "is_semantic": bool }; result is a serialized ReconResult. Setting is_semantic to true asks the node to populate tools.internal_tools by interrogating the agent.
  • _praxis/read_file — read a file on the node. Params { "agent_short_name": string, "file_type": AgentFileType, "path": string, "line_start"?: number, "line_end"?: number }, where AgentFileType is "Config" or "Session".
  • _praxis/write_file — write a file on the node. Params { "file_type": AgentFileType, "path": string, "contents": string }. There is no agent_short_name field; writes are rejected for Session file types.
  • _praxis/grep_files — regex search across one or more files. Params { "agent_short_name": string, "file_type": AgentFileType, "paths": string[], "pattern": string }.
  • _praxis/write_session_content — write agent-session content through the connector's write_session_content hook (so agents with virtual session stores can intercept the write). Params { "agent_short_name": string, "path": string, "contents": string }.

NodeCommand (non-agent concerns)

#![allow(unused)]
fn main() {
pub enum NodeCommand {
    Intercept(InterceptCommand),
    Terminal(TerminalCommand),
    Config(ConfigCommand),
    AgentRegistry(AgentRegistryCommand),
}
}

Agent and session traffic no longer flows through NodeCommand; the legacy Agent and Session variants were removed alongside the ACP migration. CommandRequest / CommandResponse still wrap NodeCommand for intercept, terminal, config, and registry traffic.

InterceptCommand

#![allow(unused)]
fn main() {
pub enum InterceptCommand {
    Enable { method: Option<InterceptMethod> },
    Disable,
}
}

TerminalCommand

#![allow(unused)]
fn main() {
pub enum TerminalCommand {
    Create,                                    // Create PTY session
    Write { data: Vec<u8> },                   // Send keystrokes
    Resize { rows: u16, cols: u16 },           // Resize terminal
    Close,                                     // Close session
    Replay,                                    // Request scrollback buffer replay
}
}

Key Data Types

NodeRegistration

#![allow(unused)]
fn main() {
pub struct NodeRegistration {
    pub node_id: String,
    pub node_type: String,
    pub machine_name: String,
    pub os_details: String,
    pub capabilities: Vec<NodeCapability>,  // Session | Interception | Terminal | Recon
}
}

SelectedAgent

#![allow(unused)]
fn main() {
pub struct SelectedAgent {
    pub short_name: String,
    pub session_id: Option<String>,
    pub process_name: Option<String>,
    pub yolo_mode: bool,
    pub working_dir: Option<String>,
    pub active_transaction_id: Option<String>,  // Transaction ID of the in-flight prompt, if any
    pub active_prompt_text: Option<String>,     // Text of the in-flight prompt, if any
}
}

ReconResult

#![allow(unused)]
fn main() {
pub struct ReconResult {
    pub config: ReconConfig,     // { items, project_paths }
    pub tools: ReconTools,        // { mcp_servers, skills, internal_tools }
    pub sessions: ReconSessions,  // { items }
}
}

SemanticOperationSpec

#![allow(unused)]
fn main() {
pub struct SemanticOperationSpec {
    pub name: String,
    pub description: String,
    pub agent_info: String,
    pub timeout: u64,
    pub operation_prompt: String,
    pub mode: String,                  // "one-shot" or "agent"
    pub agent_iterations: u32,
    pub yolo_mode: bool,
    pub model_ref: Option<String>,
}
}

InterceptedTrafficEntry

#![allow(unused)]
fn main() {
pub struct InterceptedTrafficEntry {
    pub id: Option<i64>,
    pub timestamp: DateTime<Utc>,
    pub node_id: String,
    pub agent_short_name: String,
    pub intercept_method: InterceptMethod,
    pub direction: TrafficDirection,
    pub method: Option<String>,
    pub url: String,
    pub host: String,
    pub request_headers: Option<IndexMap<String, String>>,
    pub request_body: Option<Vec<u8>>,
    pub response_status: Option<u16>,
    pub response_headers: Option<IndexMap<String, String>>,
    pub response_body: Option<Vec<u8>>,
}
}

ChainDefinitionInput

#![allow(unused)]
fn main() {
pub struct ChainDefinitionInput {
    pub name: String,
    pub description: String,
    pub category: String,
    pub elements: Vec<ChainElement>,
    pub connections: Vec<ChainConnection>,
    pub disabled: bool,
    pub timeout: Option<u64>,
    pub positions: HashMap<String, ElementPosition>,  // Saved visual canvas layout, by element ID
}
}

TriggerConfig

#![allow(unused)]
fn main() {
pub enum TriggerConfig {
    // Time-based trigger
    Scheduled { schedule: ScheduleSpec, recurring: bool },
    // Fires when intercepted traffic matches a rule
    InterceptMatch { rule_id: i64 },
    // Fires when a new node registers
    NewNode,
}

pub enum ScheduleSpec {
    // Fire once per day at hour:minute (UTC)
    DailyAt { hour: u8, minute: u8 },
    // Fire every N minutes
    Interval { minutes: u32 },
}
}

TargetSpec

#![allow(unused)]
fn main() {
pub struct TargetSpec {
    // Specific node IDs (empty = all registered nodes)
    pub node_ids: Vec<String>,
    // Case-insensitive substring filter on node os_details
    pub os_filter: Option<String>,
    // Specific agent short names (empty = all available agents)
    pub agent_short_names: Vec<String>,
    // For event triggers: include the node that triggered the event
    pub include_triggering_node: bool,
}
}

ChainTriggerInfo

#![allow(unused)]
fn main() {
pub struct ChainTriggerInfo {
    pub id: String,
    pub chain_id: String,
    pub trigger_config: TriggerConfig,
    pub target_spec: TargetSpec,
    pub enabled: bool,
    pub last_fired_at: Option<DateTime<Utc>>,
    pub next_fire_at: Option<DateTime<Utc>>,
}
}

InterceptMethod

#![allow(unused)]
fn main() {
pub enum InterceptMethod {
    Proxy,    // System proxy settings
    Vpn,      // TUN adapter
    Hosts,    // Hosts file redirection
    Tproxy,   // Linux iptables TPROXY transparent proxying; recommended default on Linux
}
}

TrafficDirection

#![allow(unused)]
fn main() {
pub enum TrafficDirection {
    Send,     // Request to LLM
    Receive,  // Response from LLM
}
}

Configuration Reference

This reference documents all configuration options for Praxis components.

Environment Variables

RabbitMQ

VariableDefaultDescription
PRAXIS_RABBITMQ_URLamqp://praxis:praxis@localhost:5672RabbitMQ connection URL

Database (Service)

VariableDefaultDescription
PRAXIS_DATABASE_URL~/.praxis/operations.dbDatabase connection

Formats:

  • postgresql://user:pass@host:5432/dbname - PostgreSQL
  • sqlite:///path/to/file.db - SQLite with URL prefix
  • /path/to/file.db - SQLite (implicit)

See Database Configuration for detailed setup.

Service

VariableDefaultDescription
PRAXIS_NODES_DIR(none)Directory containing node binaries for download. No Rust code in this repo reads it — it only appears in the Dockerfile and pkg/ packaging examples, so it may be vestigial or packaging-only rather than something Praxis itself consumes.

Build

VariableEffect
PRAXIS_NOT_HIDDENDisable hidden desktop for DevTools agents. Defaults to 1 in debug builds (visible for development) and 0 in release builds (hidden for production). Set to 1 to make the browser window visible for debugging.
PRAXIS_VERSIONDocker build arg. Version of the prebuilt release tarball to download from GitHub Releases. Defaults to the version pinned in the Dockerfile. Usage: PRAXIS_VERSION=1.0.0 docker compose up --build
PRAXIS_RELEASE_BASEDocker build arg. Base URL for the release download (without trailing /v<version>/...). Defaults to https://github.com/originsec/praxis/releases/download. Override to pull from a fork or mirror.

Logging

VariableExampleDescription
RUST_LOGinfoLog level filter
RUST_LOGdebugVerbose logging
RUST_LOGpraxis_node::intercept=debugModule-specific logging

Service Configuration

Service configuration is stored in the database and managed via the praxis TUI.

Application Logging

KeyDefaultDescription
application_logs_enabledfalseEnable centralized application/event logging from service and nodes

When disabled or missing, logging is off by default. The service broadcasts the current setting to nodes and clients at startup and on registration.

Log Query

KeyDefaultDescription
log_query_row_limit10000000Maximum rows returned from database tables in KQL log-query searches

LLM Provider Settings

Access via Settings (Ctrl+S) > LLM in the praxis TUI.

LLM configuration has two levels: a single list of named model definitions, and per-feature keys that each point at one definition by name.

KeyFormatDescription
llm_model_definitionsJSON arrayNamed model definitions. Each entry has name, provider, model, apiKey, and an optional baseUrl override.
llm_feature_semantic_parserstringName of the model definition used for semantic parsing
llm_feature_traffic_parserstringName of the model definition used for traffic analysis
llm_traffic_parser_body_limit_kbintegerMaximum text body sent to the Traffic Parser, in KiB (default: 60; larger bodies retain their beginning and end)
llm_feature_semantic_opsstringName of the model definition used for semantic operations
llm_feature_orchestratorstringName of the model definition used for the Orchestrator
llm_feature_doc_helperstringName of the model definition used for the documentation helper agent; falls back to llm_feature_orchestrator when unset

Example llm_model_definitions value:

[
  {
    "name": "sonnet",
    "provider": "anthropic",
    "model": "claude-sonnet-4-20250514",
    "apiKey": "sk-ant-..."
  },
  {
    "name": "haiku",
    "provider": "anthropic",
    "model": "claude-haiku-4-5-20241022",
    "apiKey": "sk-ant-..."
  }
]

Each llm_feature_* key stores the name of one entry above (e.g. llm_feature_orchestrator = "sonnet"). A feature with no assigned model definition, or one whose name no longer resolves, is disabled.

Prompt Timeout

KeyDefaultDescription
prompt_timeout_secs600Maximum time in seconds a single agent prompt can run before the agent process is killed. Applies to all sessions unless overridden per-session.

Claude Bridge Settings

Access via Settings (Ctrl+S) > Claude Bridge in the praxis TUI.

KeyDefaultDescription
claude_ccrv1_enabledfalseEnable the CCRv1 (WebSocket) bridge listener
claude_ccrv1_port8586Port for CCRv1 WebSocket connections
claude_ccrv2_enabledfalseEnable the CCRv2 (HTTP+SSE) bridge listener
claude_ccrv2_port8587Port for CCRv2 HTTP connections

TLS is always on for both bridges; CCRv1 only accepts wss:// and CCRv2 only accepts https://. Leaf certs are minted per SNI on the fly and signed by a self-signed CA at ~/.praxis/bridge/ca_cert.pem.

The Claude Bridge allows Claude Code to connect directly to the service as a virtual node, without deploying a full Praxis node. See Claude Bridge for protocol details and setup instructions.

MCP Server Settings

Access via Settings (Ctrl+S) > Service in the praxis TUI.

KeyDefaultDescription
mcp_server_enabledtrueEnable the built-in MCP server
mcp_server_port8585Port for the MCP server

The MCP server exposes all Praxis tools via the Model Context Protocol over streamable-HTTP transport. It is used by the built-in Orchestrator and can also be used by external AI agents. See MCP Server for full details.

Praxis Agent Settings

Access via Settings (Ctrl+S) > Agents in the praxis TUI.

KeyFormatDescription
praxis_agent_settingsJSON: {"modelRef": "<name>", "thinkingEffort": "<string>", "enabled": bool}Config for the built-in Praxis agent connector. modelRef names an entry in llm_model_definitions; thinkingEffort is a free-form string (e.g. low/medium/high) appended to the session system prompt.
praxis_agent_system_promptstringOptional system prompt override for the Praxis agent connector

Supported Providers

Provider IDNameAPI KeyBase URL
anthropicAnthropicrequiredfixed
openaiOpenAIrequiredfixed
geminiGoogle (Gemini)requiredfixed
groqGroqrequiredfixed
cerebrasCerebrasrequiredfixed
mistralMistralrequiredfixed
xaixAIrequiredfixed
nvidiaNVIDIArequiredfixed
fireworksaiFireworks AIrequiredfixed
minimaxMiniMaxrequiredfixed
moonshotMoonshot AIrequiredfixed
openrouterOpenRouterrequiredfixed
ollamaOllama (local)optionaldefaults to http://localhost:11434/v1
customCustom (OpenAI-compatible)optionalrequired

Every model definition can carry an optional base_url field that overrides the provider default. For custom the base URL is required — discovery and inference both fail without it. For ollama the base URL defaults to the local daemon; set it explicitly if you run Ollama remotely or on a non-default port.

Model Reference Format

When specifying models in operations or chains:

provider::model

Examples:

  • anthropic::claude-sonnet-4-20250514
  • openai::gpt-4o
  • gemini::gemini-1.5-pro
  • groq::llama-3.3-70b-versatile

Node Configuration

Node Commands

Nodes accept configuration commands at runtime:

CommandParameterDescription
SetReportIntervalinterval_secs: u64How often to send information updates

Agent Connector Configuration

Each agent connector may have specific configuration. See individual connector documentation.

Claude Code

  • Config path: ~/.claude/settings.json (global settings) and ~/.claude.json (preferences)
  • MCP servers: ~/.claude/mcp.json, .mcp.json, and enabled plugin MCP definitions
  • Plugins: ~/.claude/plugins/installed_plugins.json with cached components in ~/.claude/plugins/cache/
  • Sessions: ~/.claude/projects/

Gemini CLI

  • Config path: ~/.gemini/settings.json
  • Sessions: ~/.gemini/tmp/<sha256-hash>/chats/

M365 Copilot

  • Mode: DevTools (via CDP)
  • Platform: Windows only

Claude Desktop

  • Config path: %APPDATA%\Claude\claude_desktop_config.json (MCP servers), plus config.json and developer_settings.json in the same directory
  • Sessions: none — Code/Chat are static UI modes driven via CDP, not session files
  • Platform: Windows only

Codex CLI

  • Config path: ~/.codex/config.toml (MCP servers), ~/.codex/auth.json (credentials)
  • Sessions: ~/.codex/sessions/, ~/.codex/archived_sessions/

Cursor Agent

  • Config path: ~/.cursor/cli-config.json (global), .cursor/cli.json / .cursor/mcp.json (project)
  • Sessions: ~/.config/cursor/chats/<project_hash>/<chat_id>/ (SQLite store.db)

Droid CLI

  • Config path: ~/.factory/settings.json, ~/.factory/mcp.json
  • Sessions: ~/.factory/sessions/

Pi Coding Agent

  • Config path: ~/.pi/agent/settings.json (no MCP support — extensions are the intended extension mechanism)
  • Sessions: ~/.pi/agent/sessions/<encoded-cwd>/

Antigravity CLI

  • Config path: ~/.gemini/antigravity-cli/settings.json
  • Sessions: ~/.gemini/antigravity-cli/brain/

Operation Definitions

Operations are defined in JSON and stored in the service database.

JSON Format

{
  "item_type": "operation",
  "name": "find_credentials",
  "short_name": "find_credentials",
  "category": "recon",
  "description": "Search for hardcoded credentials",
  "agent_info": "Security researcher looking for exposed secrets",
  "timeout": 300,
  "operation_prompt": "Search the current directory for files that may contain hardcoded credentials, API keys, passwords, or secrets. List each finding with the file path and context.",
  "mode": "one-shot",
  "agent_iterations": 1,
  "yolo_mode": false,
  "disabled": false
}

Fields

FieldTypeRequiredDescription
item_typestringNoImport validation marker; if present must equal "operation"
namestringYesDisplay name
short_namestringYesShort name, combined with category to form full_name
descriptionstringYesHuman-readable description
categorystringYesCategory for organization
agent_infostringYesContext for the AI agent
timeoutu64No (default 60)Timeout in seconds
operation_promptstringYesThe prompt to execute
modestringNo (default one-shot)one-shot or agent
agent_iterationsu32NoMax iterations (agent mode)
yolo_modeboolNoAuto-approve actions
model_refstringNoModel override (provider::model)
disabledboolNoDisable the operation

Full Name

Operations are referenced by category::short_name, e.g., recon::find_credentials.

Chain Definitions

Chains are visual workflows stored in the service database.

Elements

Element TypeProperties
Triggerid, trigger_type
Operationid, operation_name, model_ref, session_group, block_config
Transformid, prompt, model_ref, session_group, block_config
GenericPromptid, prompt, session_group, block_config
Memoryid, mode (store or retrieve), key
Loopid, max_iterations
Toolid, tool_name, tool_params, block_config
Payloadid, payload_id, block_config
Terminationid, block_config

block_config fields (all optional):

FieldTypeDescription
max_runtimeu64Per-element timeout in seconds
yolo_modeboolAuto-approve for this element's session
working_dirstringWorking directory override
require_all_inputsboolWait for all upstream inputs before executing (default: true)

Session Groups

{
  "id": "group-1",
  "color": "#8B5CF6",
  "yolo_mode": true
}

Elements in the same session group share an agent session context.

Connections

{
  "id": "edge-1",
  "from_element": "trigger-1",
  "to_element": "op-1",
  "from_port": 0,
  "to_port": 0
}

condition is optional. Omit it entirely for a connection that always fires — when present, it must be OnSuccess or OnFailure. "Always" is not a valid value and fails to deserialize.

Intercept Rules

Rules for matching and processing intercepted traffic.

Rule Structure

{
  "name": "Capture API Keys",
  "regex_pattern": "Authorization:\\s*Bearer",
  "target_direction": "send",
  "scope": "all",
  "enabled": true,
  "summarization_prompt": "Extract and summarize the authentication tokens"
}

Target Direction

ValueDescription
sendMatch outgoing requests
receiveMatch incoming responses
bothMatch both directions

Scope

RuleScope carries no internal tag attribute (unlike sibling enums in the same file that do), so it serializes with serde's default external tagging rather than a {"type": "..."} shape.

TypeExampleDescription
all"all"All nodes/agents (bare string)
node{"node": {"node_id": "abc123"}}Specific node
agent{"agent": {"node_id": "abc123", "agent_short_name": "claudecode"}}Specific agent

Database Schema

SQLite (Default)

Default location: ~/.praxis/operations.db

Tables:

  • service_config - Key-value configuration
  • operation_definitions - Semantic operations
  • operations - Operation executions
  • operation_chains - Chain workflows
  • chain_executions - Chain runs
  • chain_triggers - Automated chain triggers (scheduled, intercept-match, new-node)
  • chain_memories - Key-value store for chain Memory elements
  • chain_payloads - Static content for Payload chain elements
  • intercepted_traffic - Intercepted traffic
  • intercept_rules - Traffic rules
  • traffic_matches - Rule matches
  • recon_results - Stored recon data
  • event_log - Centralized logging table (controlled by application_logs_enabled)
  • session_transactions - Per-prompt transaction records (request/response text, timing, status)
  • lua_agent_scripts - Lua agent connector scripts (built-in and custom)
  • toolkit_actions - Toolkit tool execution log
  • remote_nodes - Persisted remote (virtual) node bridge configs
  • agent_chat_sessions - AgentChat sessions
  • agent_chat_agents - Agents participating in an AgentChat session
  • agent_chat_channels - AgentChat channels
  • agent_chat_messages - AgentChat channel and DM messages

PostgreSQL

For production and multi-instance deployments, use PostgreSQL. See Database Configuration for setup, migration, and tuning.

Default Ports

ServicePortProtocol
MCP Server8585HTTP
Claude Bridge CCRv18586WS
Claude Bridge CCRv28587HTTP
RabbitMQ5672AMQP
RabbitMQ Management15672HTTP
PostgreSQL5432TCP
Proxy (when enabled)DynamicHTTP

CLI Configuration

The Praxis CLI (praxis) stores state and can be configured via command-line options or its config file.

CLI State File

PlatformPath
Linux/macOS~/.praxis/cli.json
Windows%USERPROFILE%\.praxis\cli.json

Contents:

{
  "client_id": "uuid-generated-on-first-run",
  "sessions": {
    "<node_id>": "<session_id>"
  }
}

sessions maps node IDs to the CLI's currently active ACP session ID on that node, populated by session create and consumed by session prompt / session close.

CLI Options

The RabbitMQ URL is never read from a -r/--rabbitmq flag or an OS environment variable — no such flag exists. It is read from ~/.config/praxis/config (key PRAXIS_RABBITMQ_URL). Use praxis set-rabbitmqurl <url> to persist it and praxis config to see the resolved URL and its source.

OptionDefaultDescription
-t, --timeout600Connection/command timeout in seconds
-C, --command-Run a single command and exit
--status-Check connection status
--clear-Clear local state (client ID)
--acp-Run as ACP stdio proxy (forward JSON-RPC over stdin/stdout to the service)
--resume-Resume a saved orchestrator session, selected from a list
--continue-Continue the most recent local orchestrator session

File Locations

Linux

FilePath
Database~/.praxis/operations.db
CLI State~/.praxis/cli.json
CLI Binary/usr/local/bin/praxis (symlink to praxis_cli)
Claude Config~/.claude/settings.json and ~/.claude.json
Gemini Config~/.gemini/settings.json

macOS

FilePath
Database~/.praxis/operations.db
CLI State~/.praxis/cli.json
CLI Binary/usr/local/bin/praxis (symlink to praxis_cli)
Claude Config~/.claude/settings.json and ~/.claude.json
Gemini Config~/.gemini/settings.json

Windows

FilePath
Database%USERPROFILE%\.praxis\operations.db
CLI State%USERPROFILE%\.praxis\cli.json
CLI Binary%USERPROFILE%\.praxis\bin\praxis.exe (copy of praxis_cli.exe)
Claude Config%USERPROFILE%\.claude.json
Hosts FileC:\Windows\System32\drivers\etc\hosts