PumaBot needs an agent orchestration framework to power its MCP server and Claude Code orchestrator (see PROPOSAL-MCP-ORCHESTRATION.md). Rather than building a custom orchestrator from scratch, we want to use Pantograph's workflow engine indirectly through its binding crates. PumaBot constructs and runs Pantograph workflows, passing data through input/output nodes and receiving events. Inference, RAG, embeddings, and model management are all consumed via Pantograph's built-in workflow nodes — PumaBot never imports Pantograph's Rust crates directly.
This document covers:
- The full integration architecture across PumaBot, Pantograph, and Pumas-Library
- Specific asks for the Pantograph team — including cross-language binding crates
- Pumas-core integration strategy
PumaBot (Elixir/Phoenix OTP)
│
├── PumaBot.MCP.Server ── Hermes MCP tools (compile, test, docs_lookup)
├── PumaBot.Terminal.Session ── PTY sessions for Claude Code instances
├── PumaBot.Terminal.Orchestrator ── High-level task dispatch (Elixir GenServer)
│
├── PumaBot.Workflow.Native ── Elixir module consuming pantograph-rustler NIFs
├── PumaBot.Workflow.Engine ── Elixir wrapper (create/edit/run workflows, I/O)
├── PumaBot.Workflow.Callbacks ── Custom node logic in Elixir (MCP, terminal)
│
└── PumaBotWeb ── Phoenix LiveView dashboard
├── TerminalDashboardLive ── Terminal grid with xterm.js
└── WorkflowDashboardLive ── Workflow graph visualization
Pantograph (Rust workspace)
│
├── crates/node-engine/ ── Workflow execution engine (library)
├── crates/workflow-nodes/ ── Built-in node implementations (library)
├── crates/inference/ ── LLM inference backends (library)
│
├── crates/pantograph-rustler/ ── NEW: Elixir/Erlang NIFs (like pumas-rustler)
└── crates/pantograph-uniffi/ ── NEW: Cross-language bindings (like pumas-uniffi)
(PumaBot consumes Pantograph only via these binding crates)
Pumas-Library (Rust workspace) — existing, consumed by Pantograph
│
├── crates/pumas-core/ ── Model management library
├── crates/pumas-rustler/ ── Elixir NIFs
└── crates/pumas-uniffi/ ── Cross-language bindings (mature)
Following the same pattern as Pumas-Library, Pantograph provides its own binding crates. PumaBot (and any other Elixir project) consumes pantograph-rustler NIFs — PumaBot writes zero Rust code.
The bindings expose full workflow CRUD (create, edit, run) plus workflow I/O — the ability to pass data into Pantograph input nodes and receive results from Pantograph output nodes. This is the primary integration surface.
Custom node logic (MCP tools, terminal management) that is PumaBot-specific is implemented in Elixir via a callback mechanism (see Ask 8). RAG, embeddings, model management, inference, and other AI capabilities are provided by Pantograph's own workflow nodes — PumaBot simply builds and runs workflows that use them.
Pantograph has existing PumaLib node(s) for utilizing pumas-core within workflows (workflow-nodes/src/input/model_provider.rs). These need revision and full implementation (see Ask 4). PumaBot accesses model management capabilities through Pantograph workflows — not via direct pumas-rustler NIFs. This keeps pumas-core as a Pantograph concern and avoids PumaBot needing a separate Rust dependency.
Problem: Pantograph is structured as a Tauri desktop app. The crates have implicit assumptions about running inside Tauri (ProcessSpawner trait, Tauri event channels). All crates need to work as pure headless libraries so the binding crates can wrap them.
What we need:
-
node-enginecrate — Already mostly headless. Needs:- Ensure no Tauri dependencies leak in (currently clean — confirmed)
- Stable public API surface:
DemandEngine,WorkflowExecutor,TaskExecutortrait,EventSinktrait, orchestration types - Add a
libraryfeature flag that excludes any desktop-specific functionality
-
inferencecrate — HasProcessSpawnerabstraction but needs:- The
std-processfeature flag (usingStdProcessSpawner) must work fully without Tauri - Ensure llama.cpp sidecar and Ollama backends work in headless mode
- The
-
workflow-nodescrate — Needs:- Feature-gate any nodes that depend on Tauri (ComponentPreview, LinkedInput)
- Ensure all processing nodes (Inference, Validation, Embedding, ToolExecutor, ToolLoop, ReadFile, WriteFile, VectorDb) work as pure library code
- The
ModelProviderTaskneeds pumas-core integration (see Ask 4)
Files to modify:
crates/node-engine/Cargo.toml— Addlibraryfeaturecrates/inference/Cargo.toml— Verifystd-processfeature completenesscrates/workflow-nodes/Cargo.toml— Feature-gate desktop-only nodes
Problem: PumaBot needs workflow nodes that don't belong in Pantograph (MCP tool invocation, terminal/PTY session management). Currently all nodes are defined inside workflow-nodes with no mechanism for external consumers to register their own node types.
What we need:
- A node registry abstraction in
node-engine:
pub struct NodeRegistry {
executors: HashMap<String, Box<dyn TaskExecutorFactory>>,
}
pub trait TaskExecutorFactory: Send + Sync {
fn create(&self, task_id: &str, config: serde_json::Value) -> Box<dyn TaskExecutor>;
fn metadata(&self) -> TaskMetadata;
}
impl NodeRegistry {
pub fn register<F: TaskExecutorFactory + 'static>(&mut self, node_type: &str, factory: F);
pub fn create_executor(&self, node_type: &str, task_id: &str, config: Value) -> Option<Box<dyn TaskExecutor>>;
pub fn available_node_types(&self) -> Vec<TaskMetadata>;
}-
Composable registries — Pantograph's built-in nodes + consumer-registered nodes unified into a single executor lookup.
-
WorkflowExecutor::demand()should use the registry to resolve node types dynamically.
Files to modify:
crates/node-engine/src/registry.rs(new)crates/node-engine/src/engine.rs— Use registry in demand path
Problem: Orchestration graphs (node-engine/src/orchestration/) are not fully implemented / don't work yet. PumaBot needs orchestration for multi-step agent workflows (e.g., compile → check errors → fix → recompile loop). The types and executor skeleton exist but the system is not functional.
Current state (from code review):
types.rs— OrchestrationGraph, OrchestrationNode, OrchestrationNodeType (Start, End, Condition, Loop, DataGraph, Merge) — definedexecutor.rs— OrchestrationExecutor with DataGraphExecutor trait — partially implementednodes.rs— Node execution logic — existsstore.rs— Persistence — exists
What we need:
- Complete the orchestration executor so it reliably runs orchestration graphs end-to-end
- Condition nodes correctly evaluate boolean context values and branch
- Loop nodes correctly iterate with max iteration limits and exit conditions
- DataGraph nodes correctly delegate to data graph execution and propagate outputs
- Merge nodes correctly combine multiple execution paths
- Error handling and recovery — when a data graph fails, route to error handling
- Integration tests covering: linear flow, conditional branching, loops with exit conditions, data graph delegation, error paths
Files to modify:
crates/node-engine/src/orchestration/executor.rscrates/node-engine/src/orchestration/nodes.rscrates/node-engine/src/orchestration/store.rs
Problem: The PumaLib/ModelProvider node(s) (workflow-nodes/src/input/model_provider.rs) have a TODO to integrate pumas-core. Currently the ModelProviderTask just passes through a model name string with no validation or registry lookup. This needs a full revision.
What we need:
-
ModelProviderTaskrevised to:- Accept a
PumasApihandle via workflow context - Call
pumas_core::list_models()andsearch_models()for model discovery - Validate that requested models exist and resolve their file paths
- Output full
ModelInfoincluding path, type, family, hashes, security tier
- Accept a
-
inferencecrate optionally uses pumas-core for:- Model path resolution (given a model name, find the GGUF/safetensors file)
- Backend selection (pumas-core knows which backends are running: Ollama, llama.cpp)
- System resource checks before inference (GPU memory, disk space)
-
Feature-gated behind
model-libraryso Pantograph can still work without it.
Files to modify:
crates/workflow-nodes/Cargo.toml— Addpumas-librarydependency behind feature flagcrates/workflow-nodes/src/input/model_provider.rs— Full revision with pumas-core integrationcrates/inference/Cargo.toml— Optionalpumas-librarydependencycrates/inference/src/gateway.rs— Model resolution via pumas-core when available
Problem: The EventSink trait (node-engine/src/events.rs) only has NullEventSink and VecEventSink. External consumers need real-time event streaming.
What we need:
BroadcastEventSink— sends to multiple subscribers viatokio::sync::broadcastCallbackEventSink— calls a user-provided closure per event (critical for NIF bridging)CompositeEventSink— fans out events to multiple sinks
Files to modify:
crates/node-engine/src/events.rs
Problem: PumaBot needs to store workflows in PostgreSQL and construct them programmatically from Elixir.
What we need:
-
Confirm
WorkflowGraph/OrchestrationGraphserde round-trip cleanly (no information loss). -
A workflow builder API for programmatic construction:
WorkflowBuilder::new("wf-1", "Compile and Test")
.add_node("process", "compile", json!({"command": "mix", "args": ["compile"]}))
.add_node("process", "test", json!({"command": "mix", "args": ["test"]}))
.connect("compile", "stdout", "test", "stdin")
.build()? // validates graph integrity- Graph validation: no cycles, required ports connected, port type compatibility, exactly one Start node in orchestration graphs.
Files to create:
crates/node-engine/src/builder.rscrates/node-engine/src/validation.rs
Problem: PumaBot needs to spawn and manage long-running processes (Claude Code CLI, mix compile, mix test). Pantograph doesn't have a general-purpose process execution node.
What we need: A ProcessTask workflow node:
- Spawns external process with configurable command, args, cwd, env vars
- Streams stdout/stderr as
TaskStreamevents - Supports stdin input (for interactive processes)
- Handles timeouts and graceful termination
- Outputs exit code, stdout, stderr
Files to create:
crates/workflow-nodes/src/system/process.rscrates/workflow-nodes/src/system/mod.rs
Problem: Pantograph currently has no cross-language bindings. For PumaBot (and other projects) to consume Pantograph's workflow engine, it needs the same binding pattern that Pumas-Library provides.
Reference implementation: Pumas-Library's binding crates:
pumas-rustler(Pumas-Library/rust/crates/pumas-rustler/) — Rustler NIF crate,cdylib, wrapspumas-libraryfor Elixirpumas-uniffi(Pumas-Library/rust/crates/pumas-uniffi/) — UniFFI crate,cdylib+lib, wrapspumas-libraryfor Python/C#/Swift/Kotlin/Ruby/Go. UsesFfiPumasApiobject withArc<PumasApi>, FFI-safe wrapper types,FfiErrorconversion from internal errors.
What we need:
crates/pantograph-rustler/
├── Cargo.toml # [lib] crate-type = ["cdylib"], depends on node-engine, workflow-nodes, inference internally
└── src/
└── lib.rs # Rustler NIF functions + FFI types
Exposed API surface (NIF functions):
Workflow CRUD:
create_workflow(id, name) -> ResourceArc<FfiWorkflowExecutor>
load_workflow_json(json_string) -> ResourceArc<FfiWorkflowExecutor>
export_workflow_json(executor) -> String
add_node(executor, node_type, id, config_json) -> :ok
remove_node(executor, node_id) -> :ok
connect(executor, source, source_port, target, target_port) -> :ok
disconnect(executor, edge_id) -> :ok
update_node_data(executor, node_id, data_json) -> :ok
validate_graph(executor) -> {:ok, []} | {:error, [validation_errors]}
Workflow I/O (the primary integration surface):
set_input(executor, node_id, port, value_json) -> :ok
# Sets data on an input node's port before or during execution
get_output(executor, node_id, port) -> {:ok, value_json} | {:error, reason}
# Reads data from an output node's port after execution
Execution:
execute_workflow(executor, output_node_id, callback_pid) -> :ok
# Demand-driven: computes everything needed for the requested output
# Runs on dirty scheduler, streams WorkflowEvent messages to callback_pid
# callback_pid receives: {:workflow_event, event_map}
execute_orchestration(executor, orch_json, initial_data, callback_pid) -> :ok
Node Registry:
list_node_types() -> [%{node_type, category, label, description, inputs, outputs}]
register_callback_node(executor, node_type, handler_pid) -> :ok
# When this node executes, it sends {:node_execute, task_id, inputs_json}
# to handler_pid and waits for {:node_result, task_id, outputs_json} response
Utility:
version() -> String
Key pattern — Elixir callback nodes: Instead of PumaBot writing custom Rust nodes, pantograph-rustler provides a generic CallbackTask that bridges back to BEAM:
/// When executed in a workflow, sends inputs to an Elixir PID and
/// waits for a response. This allows custom node logic to live in Elixir.
struct ElixirCallbackTask {
task_id: String,
handler_pid: LocalPid,
node_type: String,
env: OwnedEnv,
}
// On execution:
// 1. Sends {:node_execute, task_id, node_type, inputs_json} to handler_pid
// 2. Waits on a tokio::sync::oneshot for the Elixir process to reply
// 3. Returns the reply as task outputsThis means PumaBot implements only PumaBot-specific node logic in Elixir (MCP tools, terminal sessions). RAG, inference, embeddings, and model management are all handled by Pantograph's built-in workflow nodes.
defmodule PumaBot.Workflow.Callbacks do
use GenServer
# Called by pantograph-rustler when a callback node executes
def handle_info({:node_execute, task_id, "mcp_compile", inputs}, state) do
result = PumaBot.MCP.Tools.Compile.execute(Jason.decode!(inputs))
Pantograph.Native.node_result(task_id, Jason.encode!(result))
{:noreply, state}
end
def handle_info({:node_execute, task_id, "terminal_session", inputs}, state) do
result = PumaBot.Terminal.Session.run_task(inputs)
Pantograph.Native.node_result(task_id, Jason.encode!(result))
{:noreply, state}
end
endSame pattern as pumas-uniffi. Exposes:
FfiWorkflowEngineobject (Arc<WorkflowExecutor>)FfiNodeRegistryfor node type listingFfiWorkflowBuilderfor programmatic constructionFfiErrorwith variants for engine errors- FFI-safe wrapper types for
WorkflowEvent,GraphNode,GraphEdge, etc.
Files to create:
crates/pantograph-rustler/Cargo.tomlcrates/pantograph-rustler/src/lib.rscrates/pantograph-uniffi/Cargo.tomlcrates/pantograph-uniffi/src/lib.rscrates/pantograph-uniffi/uniffi.tomlcrates/pantograph-uniffi/src/bin/uniffi_bindgen.rs
Add to workspace Cargo.toml members:
members = [..., "crates/pantograph-rustler", "crates/pantograph-uniffi"]With Pantograph providing pantograph-rustler NIFs, PumaBot only writes Elixir:
defmodule PumaBot.Workflow.Native do
use Rustler, otp_app: :puma_bot, crate: "pantograph_rustler"
# NIF stubs auto-generated by Rustler
endlib/puma_bot/workflow/
├── engine.ex # High-level API: create/edit/run workflows, set inputs, read outputs
├── graph.ex # Elixir-friendly graph construction helpers
├── event_handler.ex # GenServer receiving events from NIF → PubSub broadcast
├── callbacks.ex # PumaBot-specific callback node handlers (MCP, terminal)
└── templates.ex # Pre-built workflow templates (compile-test-fix loop, etc.)
- mcp_compile — Calls
PumaBot.MCP.Tools.Compile.execute/1 - mcp_test — Calls
PumaBot.MCP.Tools.Test.execute/1 - terminal_session — Wraps
PumaBot.Terminal.SessionGenServer
RAG, embeddings, inference, model management, and other AI capabilities are handled by Pantograph's built-in workflow nodes — PumaBot just constructs workflows that use them and passes data via input/output nodes.
- Feature-gate Tauri-dependent code in all 3 crates
- Verify crates build and test with
--no-default-features - Validation:
cargo test --no-default-features -p node-engine -p workflow-nodes
- Complete orchestration graph implementation (Ask 3) — this is blocking
- Implement
NodeRegistrywithTaskExecutorFactory(Ask 2) - Implement
BroadcastEventSink,CallbackEventSink,CompositeEventSink(Ask 5) - Implement
WorkflowBuilder+ graph validation (Ask 6) - Implement
ProcessTasknode (Ask 7) - Validation: Build orchestration workflow programmatically, register custom node, execute with loops and conditions, receive streamed events
- Create
pantograph-rustlerwith NIF surface (workflow CRUD, I/O, execution, callback nodes) - Create
pantograph-uniffiwith FFI surface (matching rustler capabilities) - Follow
pumas-rustler/pumas-uniffipatterns exactly - Validation: From Elixir IEx — create workflow, set inputs, execute, read outputs, receive events
- Revise
ModelProviderTaskwith full pumas-core integration - Optional pumas-core model resolution in inference crate
- Validation: ModelProvider node lists real models, resolves paths, validates existence
- Add
pantograph-rustleras dependency - Build Elixir wrapper modules (engine, graph, event_handler, callbacks)
- Wire workflow events to Phoenix PubSub → LiveView
- Create workflow templates for MCP orchestration use cases
- Validation: End-to-end — create compile-test workflow in IEx, pass data to input nodes, observe events in LiveView, read results from output nodes
- Crate tests: Each Pantograph crate passes
cargo testwithlibraryfeature - Orchestration test: Orchestration graph with conditions, loops, and data graph delegation runs correctly
- Binding smoke test:
pantograph-rustlerNIF loads in Elixir, basic round-trip works - Workflow I/O test: Create workflow, set input node values from Elixir, execute, read output node values
- Callback test: Register Elixir callback node, execute workflow, verify Elixir handler called and result returned
- Event streaming test: Execute workflow, verify events arrive at subscribed Elixir PID
- End-to-end: PumaBot creates a "compile-and-test" orchestration workflow with MCP callback nodes, passes project path via input node, results stream to LiveView dashboard