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 Connector (Recommended)
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:
reconreceives 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_reconhandles this for the standard pipeline - Semantic recon (driven by
ctx.is_semantic) populatestools.internal_toolsby interrogating the agent throughconfig.session_fns - Avoid mutable global process state; return
process_pathfromfingerprintand consume it viactx.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
statetable returned bycreate_session— do not stash it in module-level Lua variables expecting to read it back insession_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:
- PATH search via
praxis.find_executables(name)- searches the system PATH - Global directories - explicit absolute paths (e.g.
/usr/local/bin) - Home directories - templates expanded per user home (e.g.
${HOME}/.local/bin) - 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 constructionglobal_dirs(table) -{ default = {...}, windows = {...} }absolute directorieshome_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
.luafile toagents/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
| Function | Arguments | Returns | Description |
|---|---|---|---|
praxis.acp_start | spec table | handle (string) | Spawn an ACP subprocess and perform the initialize handshake |
praxis.acp_create_session | handle, cwd | session_id (string) | Create an ACP session with a working directory |
praxis.acp_prompt | handle, prompt, yolo, interactive | response (string) | Send a prompt and wait for the streamed response. yolo auto-approves permission requests; interactive forwards them to the user |
praxis.acp_close | handle | — | Close the ACP session and terminate the subprocess |
The acp_start spec table:
| Field | Type | Description |
|---|---|---|
program | string | Path to the agent executable |
args | table | Command-line arguments (e.g. { "acp" } or { "--acp" }) |
cwd | string | Working 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:
| Function | Description |
|---|---|
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:
| Field | Type | Description |
|---|---|---|
process_path | string | Path to the executable |
debug_port_env_var | string | Environment variable for the debug port argument |
debug_port_format | string | Format string, e.g. "--remote-debugging-port={}" |
base_port | number | Base port number (random offset added) |
port_range | number | Range for random port selection (default 778) |
kill_existing | bool | Kill existing processes first (default true) |
use_hidden_desktop | bool | Spawn 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 chromiumoxidePagecalls. 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. Calldevtools.setup_electron_proxy(handle, url_match)once, after connecting and before the firsttransact, to attachwebContents.debuggerto the renderer webContents matchingurl_matchand install aglobalThis.cdp()bridge. Then either passdevtools.renderer_ops(handle)as the 4th argument todevtools.transact, or use thedevtools.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 lookupcdp_handle— the CDP connection handle string (cleaned up by Rust on drop)desktop_handle— the hidden-desktop handle returned alongside the CDP handle bydevtools.connect(nil ifuse_hidden_desktopwas off or no hidden desktop was created). Sinceuse_hidden_desktopdefaults totrueon Windows, this is normally set — release it insession_closeviapraxis.release_desktop(state.desktop_handle), or the hidden desktop leaksprocess_id— the spawned process PID (killed by Rust on abort or drop)
CDP API Reference
Low-level functions available on the praxis global:
| Function | Arguments | Returns | Description |
|---|---|---|---|
cdp_spawn_and_connect | config table | table with handle and desktop fields | Spawn 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_connect | port (number) | handle string | Connect to existing DevTools endpoint |
cdp_evaluate | handle, js (string) | value | Execute JavaScript, return result |
cdp_find_elements | handle, selector | count (number) | Count matching DOM elements |
cdp_click | handle, selector | — | Click an element |
cdp_type_text | handle, text | — | Insert text via CDP InsertText (handles emojis) |
cdp_press_key | handle, selector, key | — | Press a key on an element |
cdp_wait_for_element | handle, selector, retries, delay_ms | bool | Poll for element existence |
cdp_close | handle | — | Close connection, terminate process |
cdp_process_id | handle | number or nil | Get 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"):
| Function | Description |
|---|---|
uia_root | Get the desktop root element |
uia_find_window | Find a top-level window by name and/or PID |
uia_find | Find the first child/descendant matching criteria (name, control type, class name, or scope) |
uia_find_bfs | Breadth-first find — avoids the hang uia_find's Descendants scope causes on large Electron UIA trees |
uia_find_all | Find all matching children/descendants |
uia_name | Get an element's name |
uia_classname | Get an element's class name |
uia_invoke | Invoke an element via the UIA InvokePattern (click/activate) |
uia_expand | Expand an element via the UIA ExpandCollapsePattern (e.g. open a menu) |
uia_focus | Set keyboard focus to an element |
uia_send_keys | Send keystrokes to an element (focuses it first) |
uia_window_close | Close a window via the UIA WindowPattern |
uia_toggle | Toggle a checkbox/toggle element via the UIA TogglePattern |
uia_toggle_state | Get an element's toggle state: "on", "off", or "indeterminate" |
uia_bounding_rect | Get an element's bounding rectangle ({ x, y, width, height }) |
uia_set_foreground | Bring a window to the foreground |
uia_click_at | Click at screen coordinates |
uia_release | Release 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 persession/newACP request. The node's ACP server chooses thesession_id; the agent must build a session that does not share mutable state with any other session.drop_session(session_id)— called onsession/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:
-
Open Settings → Intercept in the praxis TUI.
-
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 -
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 beforetransactruns. 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 callingtransact, sosession/canceland in-loop polling share one flag.transact_with_context(&self, prompt: &str, ctx: SessionTransactContext) -> Result<String>— the default implementation wiresset_cancel_flagand registersacp_handle's update/permission channels, then callstransact. 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
- Build the node:
cargo build -p praxis_node - Run with the target agent installed
- Check fingerprinting works
- Test reconnaissance
- Test session creation and prompts
- 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)