External Framework Integrations
LeafMesh orchestrates external AI frameworks as agents in your mesh. Any CrewAI crew, LangGraph graph, AutoGen team, A2A agent, MCP tool server, Zapier action, Composio integration, or n8n workflow can participate in can_call chains and wait_for patterns alongside your native LeafMesh agents.
External agents use agent_type: "external" in YAML. LeafMesh handles connector lifecycle, request/response serialization, polling, and error propagation automatically. Your Python function receives the external framework's response and post-processes it into structured yields.
How External Agents Work
Every external agent follows the same pattern:
- YAML declares the agent with
agent_type: "external"and aframeworkkey - connector_config passes framework-specific settings (endpoints, keys, tool names)
- Python function post-processes the external response into your mesh's yield schema
- can_call / wait_for wires the external agent into your mesh like any other agent
mesh_call("entry_point", data)
--> native agent (can_call) --> external agent --> native agent
|
v
CrewAI / LangGraph / AutoGen / A2A
MCP / Zapier / Composio / n8n
Data Flow
All connectors use a standard request/response envelope:
- In: Your agent's
input_datadict is sent to the external framework - Out: The framework's response arrives as
external_responsein your Python function - Yields: Your function returns a dict matching the agent's
yieldsschema
# Every external agent function has this signature
async def my_external_agent(external_response, input_data, context):
# external_response = raw output from the framework
# input_data = what the calling agent passed
# context = session context, upstream yields, etc.
return {"my_yield": external_response.get("result")}1. CrewAI
Delegates tasks to a deployed CrewAI Enterprise crew. The connector hands off the request and returns the crew's final result.
Install
pip install httpxCredentials
Make a record under Infrastructure → External agents → CrewAI. It holds the crew's endpoint and API key; the agent names it with connection:. Addresses and keys are entered only there: written inline in connector_config, including an ${{ENV}} reference, they are refused at load.
YAML Configuration
agents:
crewai_research:
name: "crewai_research"
agent_type: "external"
framework: "crewai"
description: "Delegates research tasks to a CrewAI crew"
connector_config:
connection: "crewai-research" # Infrastructure → External agents → CrewAI: endpoint + API key
poll_interval: 2.0 # seconds between status checks
max_poll_seconds: 300 # timeout for crew completion
yields:
research_result: "string"
sources: "list"
inputs:
topic: "string"
can_call:
- agent: "advisor_agent"Python Implementation
Create agency/crewai_research_agent.py:
async def crewai_research_agent(external_response, input_data, context):
"""Post-process CrewAI crew results into mesh yields."""
return {
"research_result": external_response.get("output", ""),
"sources": external_response.get("sources", []),
}Data Flow
| Direction | Content |
|---|---|
| Sent to CrewAI | Your agent's input_data dict, passed as crew inputs |
| Returned | The crew's final result, delivered as external_response to your Python function |
2. LangGraph
Runs a deployed LangGraph graph via the LangGraph Platform. Supports two execution paths: the official langgraph-sdk (preferred) or a built-in HTTP fallback.
Install
# Option A: Official SDK (preferred)
pip install langgraph-sdk
# Option B: HTTP fallback
pip install httpxCredentials
Make a record under Infrastructure → External agents → LangGraph. It holds the deployment URL and API key; the agent names it with connection:. Addresses and keys are entered only there: written inline in connector_config, including an ${{ENV}} reference, they are refused at load.
YAML Configuration
agents:
langgraph_workflow:
name: "langgraph_workflow"
agent_type: "external"
framework: "langgraph"
description: "Runs a LangGraph stateful workflow"
connector_config:
connection: "langgraph-main" # Infrastructure → External agents → LangGraph: URL + API key
graph_id: "my_graph" # graph name on the platform
poll_interval: 1.0
max_poll_seconds: 300
yields:
workflow_result: "object"
inputs:
query: "string"
can_call:
- agent: "processor_agent"Python Implementation
Create agency/langgraph_workflow_agent.py:
async def langgraph_workflow_agent(external_response, input_data, context):
"""Post-process LangGraph graph output."""
return {"workflow_result": external_response}Data Flow
| Direction | Content |
|---|---|
| Sent to LangGraph | Your agent's input_data dict, passed as the graph's input |
| Returned | The graph run's result, delivered as external_response to your Python function |
3. AutoGen
Runs an AutoGen multi-agent conversation in-process. The connector creates a ConversableAgent pair and executes the conversation locally using your configured model.
Install
pip install pyautogenYAML Configuration
agents:
autogen_team:
name: "autogen_team"
agent_type: "external"
framework: "autogen"
description: "Multi-agent conversation via AutoGen"
connector_config:
model: "gpt-4o" # model for the AutoGen agents
max_rounds: 10 # max conversation turns
yields:
team_result: "string"
inputs:
message: "string"
can_call:
- agent: "advisor_agent"Python Implementation
Create agency/autogen_team_agent.py:
async def autogen_team_agent(external_response, input_data, context):
"""Post-process AutoGen conversation result."""
return {"team_result": external_response}Data Flow
| Direction | Content |
|---|---|
| Sent to AutoGen | input_data["message"] as the conversation starter |
| Returned | The final conversation result, delivered as external_response |
4. A2A (Agent-to-Agent Protocol)
Communicates with any A2A-compliant agent server, including automatic agent discovery and support for long-running tasks. Works with the official a2a-sdk or the built-in HTTP fallback.
A2A 0.3 and A2A 1.0 are both supported. You don't choose: the connector reads the version from the agent's card, or from how the server answers, and speaks that dialect. One mesh can call 0.3 and 1.0 agents side by side.
Install
# Option A: Official SDK (preferred)
pip install a2a-sdk
# Option B: HTTP fallback
pip install httpxCredentials
Make a record under Infrastructure → External agents → A2A Protocol. It holds the agent's URL and, for authenticated agents, its token; the agent names it with connection:. Addresses and keys are entered only there: written inline in connector_config, including an ${{ENV}} reference, they are refused at load.
YAML Configuration
agents:
a2a_partner:
name: "a2a_partner"
agent_type: "external"
framework: "a2a"
description: "Communicates with an A2A-compatible agent"
connector_config:
connection: "partner-a2a" # Infrastructure → External agents → A2A Protocol: URL + token
auth_scheme: "Bearer" # default: Bearer
poll_interval: 2.0
max_poll_seconds: 300
yields:
partner_response: "object"
inputs:
message: "string"
can_call:
- agent: "processor_agent"Python Implementation
Create agency/a2a_partner_agent.py:
async def a2a_partner_agent(external_response, input_data, context):
"""Post-process A2A agent response."""
return {"partner_response": external_response}Data Flow
| Direction | Content |
|---|---|
| Sent to A2A agent | input_data["message"] as the message to the remote agent; your request_form goes in the message's metadata |
| Returned | The agent's task result, delivered as external_response, plus the task's history as steps (see *What an External Agent Reports Back* below) |
A conversation continues across calls: the A2A context id from one call is sent with the next in the same session. If a connector's own time limit runs out, the remote task is cancelled too, not left running.
5. MCP (Model Context Protocol)
Calls a single tool on an MCP server. Supports two transport modes: stdio (launches a local subprocess) and http (connects to a remote server). The MCP session is kept alive across calls for performance.
Works with both the 1.x and 2.x releases of the mcp package. A tool that answers with an error is a failed call on both, and a stdio server process is always stopped when the mesh stops. Your request_form fields are added to the tool's arguments — a tool with a strict input schema refuses fields it does not know, and that refusal is a failed call.
Install
# Full MCP support (stdio + http)
pip install mcp
# HTTP-only fallback
pip install httpxYAML Configuration (HTTP Transport)
agents:
mcp_tools:
name: "mcp_tools"
agent_type: "external"
framework: "mcp"
description: "Access tools from a remote MCP server"
connector_config:
transport: "http"
tool_name: "get_weather"
connection: "weather-mcp" # Infrastructure → Integrations → MCP: server URL + token
yields:
tool_results: "object"
inputs:
city: "string"YAML Configuration (stdio Transport)
agents:
mcp_local:
name: "mcp_local"
agent_type: "external"
framework: "mcp"
description: "Local MCP tool server via stdio"
connector_config:
transport: "stdio"
command: "npx"
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
tool_name: "read_file"
yields:
tool_results: "object"
inputs:
path: "string"Python Implementation
Create agency/mcp_tools_agent.py:
async def mcp_tools_agent(external_response, input_data, context):
"""Post-process MCP tool call result."""
return {"tool_results": external_response}Integration Helpers
You can wire MCP tools into an existing agent's pipeline without creating a separate external agent. The mcp() helper works with @pre_compose, @chain, and @conditional_chain.
From 2.4.497 the helper names a vault record instead of an address. Make the record on the Tools page; it holds the server's address and sign-in. Code that still passes an address (mcp("https://…", "tool")) or an inline auth_token is refused, with a message naming the record to create.
from leafmesh import pre_compose, chain, conditional_chain, mcp
# Before the model call: add an MCP tool's result to the context
@pre_compose(context_processor=mcp("get_weather", connection="weather_mcp"))
async def weather_agent(llm_response, input_data, context):
return {"forecast": llm_response}
# After the agent: store the result through an MCP tool
@chain(mcp("store_result", connection="results_mcp"))
async def analysis_agent(llm_response, input_data, context):
return {"analysis": llm_response.get("summary")}
# Only when the agent asks for it
@conditional_chain(
lambda r, ctx: r.get("needs_enrichment"),
mcp("enrich_data", connection="enrich_mcp"),
)
async def enricher(llm_response, input_data, context):
return {"needs_enrichment": True, "data": llm_response}A local server that you launch by command keeps its command in code. Its keys come from the vault: each row of the record becomes an environment variable of that process.
@chain(mcp("lookup", connection="local_tools", transport="stdio",
command="npx", args=["-y", "@acme/mcp-server"]))
async def looker(llm_response, input_data, context):
return llm_responseData Flow
| Direction | Content |
|---|---|
| Sent to MCP server | Your input_data dict, passed as tool arguments |
| Returned | The tool's result content, delivered as external_response |
6. Zapier
Triggers Zapier actions to connect with 8,000+ apps, through Zapier MCP. Zapier retired AI Actions, the REST API the old api_key reached; a Zapier agent configured with only an api_key now fails with that explanation. Use an MCP key (mcp_key), saved on the Zapier record.
Install
pip install httpxCredentials
Make a record under Infrastructure → Integrations → Zapier. It holds the Zapier MCP key; name it with vault_connection:. Keys are entered only there: written inline in connector_config, including an ${ENV} reference, they are refused at load.
YAML Configuration
agents:
zapier_gmail:
name: "zapier_gmail"
agent_type: "external"
framework: "zapier"
description: "Send emails via Zapier Gmail integration"
connector_config:
connection: "gmail" # the Zapier app (tool = gmail_send_email)
action: "send_email"
vault_connection: "zapier-mcp" # Infrastructure → Integrations → Zapier: holds mcp_key
timeout: 60.0
yields:
action_result: "object"
inputs:
to: "string"
subject: "string"
body: "string"Python Implementation
Create agency/zapier_gmail_agent.py:
async def zapier_gmail_agent(external_response, input_data, context):
"""Post-process Zapier action result."""
return {"action_result": external_response}Integration Helpers
Wire Zapier actions into any agent's pipeline. The zapier() helper works with @pre_compose, @chain, and @conditional_chain. From 2.4.485 every helper takes a vault record: zapier("<action name from your Zapier MCP server>", connection="<Zapier record>"). The action string is an action your own Zapier MCP server offers (for example gmail_send_email); the record, made under Infrastructure → Integrations → Zapier, holds the Zapier MCP key. A key or address passed in code is refused.
from leafmesh import conditional_chain, zapier
# Action names come from your own Zapier MCP server; gmail_send_email is an example.
# Conditionally send email after agent
@conditional_chain(
lambda r, ctx: r.get("send_email"),
zapier("gmail_send_email", connection="zapier-main"),
)
async def email_handler(llm_response, input_data, context):
return {"send_email": True, "to": "user@example.com", "body": "..."}Data Flow
| Direction | Content |
|---|---|
| Sent to Zapier | Your input_data dict, passed as action parameters |
| Returned | The action's result, delivered as external_response to your Python function |
7. Composio
Executes Composio actions to access 250+ pre-built integrations (GitHub, Slack, Gmail, Jira, Salesforce, and more) with managed authentication. The connector uses the Composio Python SDK.
Install
Included with pip install leafmesh — composio-core is a mandatory dependency, no extra install needed.
Credentials
Make a record under Infrastructure → Integrations → Composio. It holds the Composio API key; the agent names it with connection:. Addresses and keys are entered only there: written inline in connector_config, including an ${{ENV}} reference, they are refused at load.
YAML Configuration
agents:
composio_github:
name: "composio_github"
agent_type: "external"
framework: "composio"
description: "Star a GitHub repo via Composio"
connector_config:
action: "GITHUB_STAR_A_REPOSITORY_FOR_THE_AUTHENTICATED_USER"
connection: "composio-main" # Infrastructure → Integrations → Composio: API key
entity_id: "default"
yields:
integration_result: "object"
inputs:
owner: "string"
repo: "string"YAML Configuration (Nested Inputs)
For Composio actions with structured request bodies (e.g. Google Calendar), use nested inputs so the frontend renders individual sub-field editors:
agents:
composio_calendar:
name: "composio_calendar"
agent_type: "external"
framework: "composio"
description: "Create a Google Calendar event via Composio"
connector_config:
action: "GOOGLECALENDAR_CREATE_EVENT"
connection: "composio-main" # Infrastructure → Integrations → Composio
yields:
data:
type: object
fields:
event_id: string
status: string
inputs:
connected_account_id: string
user_id: string
arguments:
type: object
fields:
summary: string
start_datetime: string
end_datetime: string
timezone: stringThe nested arguments.fields definition tells the frontend to render summary, start_datetime, end_datetime, and timezone as individual input fields inside the arguments object, rather than a single text box.
Python Implementation
Create agency/composio_github_agent.py:
async def composio_github_agent(external_response, input_data, context):
"""Post-process Composio action result."""
return {"integration_result": external_response}Integration Helpers
Wire Composio into any agent's pipeline. The composio() helper works with @pre_compose, @chain, and @conditional_chain. It takes the Composio record (Infrastructure → Integrations → Composio): composio("ACTION", connection="<record>"). A key passed in code is refused.
from leafmesh import pre_compose, chain, conditional_chain, composio
# Before LLM call — fetch GitHub repo data
@pre_compose(
context_processor=composio(
"GITHUB_GET_A_REPOSITORY",
connection="composio-main",
params={"owner": "leafcraftstudios", "repo": "leafmesh"},
)
)
async def repo_agent(llm_response, input_data, context):
return {"result": llm_response}
# After agent — post to Slack via Composio
@chain(composio("SLACK_SEND_MESSAGE", connection="composio-main", params={"channel": "#alerts"}))
async def alert_agent(llm_response, input_data, context):
return {"text": llm_response.get("alert")}
# Conditionally create Jira ticket after agent
@conditional_chain(
lambda r, ctx: r.get("needs_ticket"),
composio("JIRA_CREATE_ISSUE", connection="composio-main"),
)
async def support_agent(llm_response, input_data, context):
return {"needs_ticket": True, "summary": "Bug in login flow"}Data Flow
| Direction | Content |
|---|---|
| Sent to Composio | Your input_data dict, passed as action parameters |
| Returned | The action's execution result, delivered as external_response |
8. n8n
Triggers n8n workflows via webhook URLs. n8n exposes workflows as webhooks -- this connector hands off your data to the webhook and returns the workflow's response.
Install
pip install httpxCredentials
Make a record under Infrastructure → Integrations → n8n. It holds the workflow's webhook URL and, optionally, its auth token and the instance's API key; the agent names it with connection:. Addresses and keys are entered only there: written inline in connector_config, including an ${{ENV}} reference, they are refused at load.
YAML Configuration
agents:
n8n_enrich:
name: "n8n_enrich"
agent_type: "external"
framework: "n8n"
description: "Trigger an n8n workflow for data enrichment"
connector_config:
connection: "n8n-enrich" # Infrastructure → Integrations → n8n: webhook URL + token
timeout: 60.0
yields:
workflow_result: "object"
inputs:
payload: "object"
can_call:
- agent: "processor_agent"Python Implementation
Create agency/n8n_enrich_agent.py:
async def n8n_enrich_agent(external_response, input_data, context):
"""Post-process n8n workflow response."""
return {"workflow_result": external_response}Integration Helpers
Wire n8n into any agent's pipeline. The n8n() helper works with @pre_compose, @chain, and @conditional_chain. It takes the n8n record that holds the workflow's webhook URL and token (Infrastructure → Integrations → n8n): n8n(connection="<record>"). A webhook URL or token passed in code is refused (2.4.485).
from leafmesh import pre_compose, chain, conditional_chain, n8n
# Before LLM call — enrich context with n8n workflow
@pre_compose(
context_processor=n8n(connection="n8n-enrich")
)
async def enrichment_agent(llm_response, input_data, context):
return {"result": llm_response}
# After agent — trigger n8n notification workflow
@chain(n8n(connection="n8n-notify"))
async def alert_agent(llm_response, input_data, context):
return {"event": "completed", "data": llm_response}
# Conditionally trigger n8n workflow
@conditional_chain(
lambda r, ctx: r.get("trigger_workflow"),
n8n(connection="n8n-process"),
)
async def processor(llm_response, input_data, context):
return {"trigger_workflow": True, "payload": llm_response}Data Flow
| Direction | Content |
|---|---|
| Sent to n8n | Your input_data dict, sent to the configured webhook URL, with your request_form fields in the same body and the mesh session id as _leafmesh_session_id |
| Returned | The n8n workflow's response, delivered as external_response |
The workflow reads everything as $json.body. _leafmesh_session_id lets a workflow keep its own memory per conversation (for example a Window Buffer Memory keyed on it).
Seeing each node's result. n8n's webhook reply is only the last node's output. Add the instance's n8n API key as the record's api_key field (scopes: execution list and read) and the connector looks up this call's own execution and reports every node as a step (see What an External Agent Reports Back below) — including the node that failed and why. Each call carries its own request id, so the lookup never picks up another run. Without the key, the call works the same, with no steps.
connector_config:
connection: "n8n-prod" # the record holds webhook_url and, for per-node steps, api_keyMesh Patterns with External Agents
External agents participate in all standard mesh patterns. Here are common configurations.
can_call Chains
Route from a native agent to an external agent, then back to a native agent:
agents:
triage_agent:
name: "triage_agent"
agent_type: "llm"
can_call:
- agent: "crewai_research"
condition: "calling_agent_response.needs_deep_research == true"
- agent: "processor_agent"
condition: "calling_agent_response.needs_deep_research == false"
crewai_research:
name: "crewai_research"
agent_type: "external"
framework: "crewai"
connector_config:
connection: "crewai-research" # Infrastructure → External agents → CrewAI: endpoint + API key
can_call:
- agent: "advisor_agent"wait_for Fan-In
Use an external agent as one branch of a fan-in pattern:
agents:
processor_agent:
name: "processor_agent"
agent_type: "programmatic"
can_call:
- agent: "langgraph_workflow"
- agent: "native_researcher"
advisor_agent:
name: "advisor_agent"
agent_type: "llm"
wait_for: "processor_agent AND (langgraph_workflow OR native_researcher)"
wait_for_timeout: 120The advisor activates as soon as processor_agent completes AND whichever research path (LangGraph or native) finishes first.
Mixed External Agents
Combine multiple external frameworks in one mesh:
agents:
intake_agent:
name: "intake_agent"
agent_type: "llm"
can_call:
- agent: "mcp_tools"
condition: "calling_agent_response.needs_tools == true"
- agent: "a2a_partner"
condition: "calling_agent_response.needs_partner == true"
- agent: "composio_github"
condition: "calling_agent_response.needs_github == true"
final_agent:
name: "final_agent"
agent_type: "llm"
wait_for: "intake_agent AND (mcp_tools OR a2a_partner OR composio_github)"Governing External Agents (external_policy)
An external agent is someone else's system: the mesh sends it data and puts its answer in front of the next agent. One optional external_policy: block on the agent says what may go out, what may come back, and when to stop calling it. An agent with no block at all is already safe — every default is the strict one.
The same rules govern a programmatic agent that calls a connector through integration: — it is held to the same hosts, fields, reply size and quarantine as an external agent.
agents:
n8n_enrich:
name: "n8n_enrich"
agent_type: "external"
framework: "n8n"
connector_config:
connection: "n8n-enrich" # the record holds the webhook URL
external_policy:
allowed_hosts: ["api.example.com", "*.example.com"]
max_response_bytes: 1000000 # default 1 MB
quarantine_after: 5 # default 5 failed calls in a row
send_fields: ["company", "domain"]
redact: ["\\b\\d{16}\\b"] # extra patterns masked in what is sent
yields:
employees: "number"| Field | Default | Meaning |
|---|---|---|
allowed_hosts | [] | Hosts the agent may call besides the host of its own configured address (url, endpoint, webhook_url, base_url, or the vendor default). Exact names, or *.example.com for any subdomain. Any other host is refused before the request is sent. |
max_response_bytes | 1000000 | Largest reply accepted. A larger reply is refused as a failure — never cut short. |
quarantine_after | 5 | Failed calls in a row after which the agent is quarantined. A success resets the count. |
send_fields | all fields | Only these top-level input fields are sent. When it is set, nothing else goes — the conversation history, chain history and rerun note too go only when you list them. |
redact | [] | Extra regular expressions replaced with [redacted] in everything sent. |
Always on, whatever the block says:
- Non-local hosts must use HTTPS, and redirects are never followed. A private or link-local address is refused unless it is the agent's own configured host, so a local n8n in development keeps working.
- Vault secrets and internal
_fields are never sent (withoutsend_fields, the conversation history, chain history and rerun note still are). - A policy that does not validate refuses the call; it is never replaced by looser defaults.
- Every answer is marked as coming from outside, and the next agent's prompt shows it as quoted data, not instructions.
- Each call's record — hashes of what was sent and received, outcome, duration, cost — is attached to that call's trace.
Quarantine and release
After quarantine_after failed calls in a row, the agent is quarantined: further calls fail at once without reaching it, and the Inbox is told. An A2A agent whose Agent Card changes is quarantined too, until a person re-approves it. The approved card is kept per agent, address and tenant.
A call that was stopped because its session was cancelled is neither a success nor a failure: it never moves the count toward quarantine.
| Endpoint | Purpose |
|---|---|
POST /api/agents/{name}/quarantine | Stop an external agent now. Optional body: reason, by. |
POST /api/agents/{name}/release | Release it: clears the failure count; a changed A2A Agent Card becomes the approved one. |
Approval is routing
There is no per-action approval inside the policy. The old skills risk levels (send, spend, delete) and unknown_skill_risk are refused at load when set to one of those. To have a person approve before an external agent acts, route the work to a human agent with a can_call condition, and give that human agent the can_call to the external agent.
Budgets
Every external agent can have a monthly_token_budget or monthly_cost_budget. It is checked before every call, and the agent stops when it is spent. Where the platform reports what a call used, that is counted; where it does not, the mesh estimates each call from what it sends and receives. Set price_per_1k_tokens to give an estimated agent a cost, so a cost budget applies to it. A call that would put the agent ahead of the month's even pace is held back, and budget_on_exhaust decides whether it stops or escalates. Calls racing for the last of a budget cannot overspend it, and a stop for budget is never passed on as an answer — the next agent does not run on it. See Per-Agent Budgets.
Sending Your Own Fields (request_form)
Many systems take more than the message: an n8n workflow reads a customer_tier, a LangGraph graph reads a region from its configuration, an A2A agent reads metadata. request_form is a set of keys and values you design, sent with every request to the external agent, placed where that system expects caller data.
agents:
enrich:
name: "enrich"
agent_type: "external"
framework: "n8n"
connector_config:
connection: "n8n-prod"
request_form:
source: "leafmesh"
region: "eu"
limits: { max_rows: 50 }Where each system receives it:
framework | Where the form goes |
|---|---|
n8n | The webhook body ($json.body) |
a2a and the A2A platforms (servicenow, sap_joule, workday, box, atlassian_rovo, gemini_enterprise) | The message's metadata (A2A 0.3 and 1.0) |
langgraph | config.configurable |
dify, wordware, crewai | inputs |
flowise | overrideConfig.vars |
lyzr | system_prompt_variables |
vellum | inputs (one entry per key) |
agentforce | variables (one entry per key) |
stackai, bedrock_agentcore | The request body |
claude_managed, foundry_agents, mistral_agents, glean_agents | metadata |
vertex_agent_engine | input |
copilot_studio | The message activity's value |
mcp, zapier, composio | The tool's arguments |
Refused at load — these have no place for caller data on the path LeafMesh uses: writer, watsonx, autogen, hermes, relevance, claude_agent, openai_agents, openai_agents_api, letta, and custom (a custom agent's own function reads connector_config instead).
The rules:
- Any keys and any JSON values (text, numbers, true/false, null, lists, maps).
- At most 50 keys and 16 KB in all. A key is 1–64 letters, digits,
_,-or., and may not start with_(that prefix is the SDK's own). - No secrets. A secret written in the form is refused at load. Credentials the system needs belong on the agent's record, named with
connection:. - The form fills keys, it never replaces one: if the SDK already sets a key there (the message, a session or conversation id), the SDK's value is sent and the load warns you.
- Values are never logged; only key names appear in the trace.
- It works on external agents and on programmatic agents with an
integration:. Any other agent type is refused. - A change saved from Studio or the API applies from the next call, with no restart.
Secrets in connector_config
From 2.4.481 keys, tokens, passwords, client ids, addresses (URLs, endpoints,
hosts) and header or query rows are entered only in the vault: make the
record under Infrastructure → External agents → the framework (Zapier, n8n,
Composio and MCP under Infrastructure → Integrations) and name it with
connection: (Zapier: vault_connection:). That covers url, base_url,
endpoint, webhook_url, headers, api_key, token, secret,
client_id / client_secret, tenant_id, aws_*, service_account_json
and the like. Written inline, including an ${ENV:default} reference, they
are refused at load, and the error names the field and the record to create.
The connection name itself may come from the environment:
connection: "${PARTNER_CONNECTION:partner-a2a}" is fine.
connector_config:
connection: "partner-a2a" # holds url and tokenIf the record does not exist yet, the mesh still boots: the agent is switched off with the reason and starts working as soon as the record exists, with no restart.
A record with no address (2.4.490+). An external agent or integration whose record has no address is switched off at start, and checked again on every vault save. It is never called and never quarantined, and it switches back on by itself once the address is added. Studio shows the reason.
The record sets the kind (2.4.491+). An external agent set up on Studio's
External agents page (or with POST /api/external-agents) takes its
framework from the vault record it picks. A custom record must be
Custom: A2A (a2a) or Custom: API (custom_api). A record that does
not fit is refused there with one of:
the vault record '<name>' is a <Label> record — this agent is <Label>, not <Other>the vault record '<name>' is a custom record — choose Custom: A2A or Custom: API for it (a2a or custom_api)
In YAML, an agent picks a set-up external agent by name and writes no
framework or connector_config beside it:
agents:
partner_quote:
name: "partner_quote"
external_agent: "partner-quoting" # set up on the External agents pageWriting framework or connector_config next to external_agent is refused
at load: framework is set on the External agents page for '<name>' — remove it from agent '<agent>' (it picks external_agent: <name>). The older inline
style, with framework and connector_config in the agent and no
external_agent, still loads as before and is not checked against a record.
| Endpoint | Purpose |
|---|---|
GET /api/external-agents/records | 2.4.491+. With no framework, every record an external agent may use: {name, status, framework, framework_label, custom} (framework is null for a custom record). |
POST /api/external-agents/from-spec | 2.4.491+. Body {"spec": "<Swagger/OpenAPI text>"}. Lists the POST, PUT and PATCH operations (method, path) a Custom API agent can call, and the spec's servers. It saves nothing: the address still goes only in the vault. |
The record is resolved on every call, so a credential rotated on the Infra page is used from the next call without a restart, and a tenant's own record of the same name is used for that tenant's runs. See Bring Your Own Vault. The only place a single vault value is written as a reference is the env of an mcp stdio process: ${vault:<field>}.
When you read an agent back (GET /api/agents, GET /api/yaml/agents/{name}), secrets, webhook paths and Authorization values are masked, so you can see which connection a field uses without seeing its value.
Conversations Carry On
When the external system has its own idea of a conversation, a follow-up in the same LeafMesh session continues the same conversation on the far side. The handle the system gives back is kept for that agent, session and tenant, for as long as the session lives, and sent with the next call:
framework | What continues |
|---|---|
a2a | The context id |
agentforce | The Agentforce session |
claude_agent | The Agent SDK session (resumed) |
openai_agents | The previous response |
dify | The Dify conversation |
langgraph | The thread |
watsonx | The thread |
relevance | The conversation |
hermes | The session |
stackai | The user id |
n8n | The mesh session id, sent as _leafmesh_session_id |
claude_managed | The Managed Agents session |
openai_agents_api | The agent session |
foundry_agents | The Foundry conversation |
vertex_agent_engine | The Agent Engine session |
bedrock_agentcore | The runtime session |
copilot_studio | The Direct Line conversation |
mistral_agents | The Mistral conversation |
letta | The Letta conversation |
Two sessions or two tenants never share one. A handle is kept only from a call that succeeded, and if the far side has forgotten it, it is dropped and a fresh conversation starts.
What an External Agent Reports Back
Besides its answer, each connector reports the steps its system ran — the tool calls, nodes, tasks or messages that system records — as a short list: each step's name, kind, whether it worked and, if not, a short reason. Labels and reasons only, never the data; at most 30 steps, with the rest counted.
Examples: an n8n workflow's nodes (with api_key set), the history of an A2A task, a CrewAI crew's tasks, a LangGraph thread's history, Dify and Vellum node events, AutoGen run messages, Hermes function calls, Writer's graph lookups. Zapier, MCP and Composio report their one action.
Where the steps show up:
- The trace of the run, as child steps of the external call.
- The feed, as one line:
steps: look up customer ✓, send email ✗ (mailbox full). - Lessons. The agent's experience is judged from the run's record — what happened and why, retries, the fields sent and returned, whether the reply matched last time, the failure streak, quarantine — and a successful run that took a new path of steps can become a lesson.
- The board ticket, as its checklist.
The record never holds addresses, values or contact details, and is never handed to the next agent or to another external system. Cost and time stay in the trace; they are not part of the record the feed and lessons read. When the system reports what a call cost, it is counted in the run's budget; when it reports nothing, the trace says the price is unknown.
A run that stopped early — truncated, timed out, paused or cancelled — says so on its record.
Stopping a Call
When a session is cancelled (for example, stopped from the Control page) while an external agent is still working, the call stops: the request in flight is abandoned, polling stops, and the connector asks the system to cancel its job where the system allows it. The stopped call never counts toward quarantine.
Changing an External Agent While the Mesh Runs
These changes apply to a running mesh, with no restart:
framework,connectionand anyconnector_configsetting — the agent's connector is rebuilt.- A credential saved, rotated or deleted on the Infra page — every agent using that connection picks it up.
can_call— routing follows the new list on every route.external_policyandrequest_form— read on every call.
Through the API, PATCH /api/yaml/agents/{name} merges one level deep: sending {"external_policy": {"quarantine_after": 3}} changes that one field and keeps send_fields and allowed_hosts. Inside such a block, a key sent as null is removed. PUT /api/yaml/agents/{name} replaces the agent whole.
On claude_agent, cwd, allowed_tools and permission_mode give the agent rights on the host machine, so the API never changes them. From 2.4.485 you change them on the host with the CLI, and the change is saved as a new version of the agency's configuration:
leafmesh agent host-rights <agent> [--cwd DIR] [--allowed-tools a,b] [--permission-mode M] [--clear FIELD] [--yes]--cwd sets the working directory, --allowed-tools the comma-separated tools it may use, and --permission-mode its permission mode: exactly default, acceptEdits, plan or bypassPermissions (anything else is refused by the command). --clear takes cwd, allowed_tools or permission_mode and can be repeated. --yes applies the change without asking.
Supported Frameworks
The connectors documented in detail above are the most common ones. LeafMesh ships connectors for 42 frameworks and platforms in total, plus custom. Each is selected with the matching framework key on an agent_type: "external" agent. Its connector_config fields are discoverable at runtime via GET /api/registry?category=connectors — query it to see the exact fields each connector accepts in your deployment.
Multi-agent frameworks
framework | Purpose | Integration Mode |
|---|---|---|
crewai | Run a CrewAI crew as an agent | Remote service |
langgraph | Run a LangGraph graph as an agent | Remote service (SDK or HTTP fallback) |
autogen | Run an AutoGen team as an agent | Remote service |
a2a | Call any Agent-to-Agent protocol agent | Remote service (SDK or HTTP fallback) |
custom_api | Call an agent of your own behind an HTTP API (a Custom: API record, 2.4.486) | Remote service |
Agent platforms reached over A2A
Each of these is the A2A connector under the platform's own name, so it has its own entry in Studio's list, its own card and connections on the Infra page, and its own name in traces and cost. Its connector_config is the same as a2a: the record's url is the agent's A2A address (its agent card URL).
framework | Platform |
|---|---|
servicenow | ServiceNow AI Agents |
sap_joule | SAP Joule agents |
workday | Workday agents |
box | Box AI agents |
atlassian_rovo | Atlassian Rovo agents |
gemini_enterprise | Gemini Enterprise agents |
In-process agent SDKs
framework | Purpose | Integration Mode |
|---|---|---|
claude_agent | Run the Claude Agent SDK in-process | In-process library |
openai_agents | Run the OpenAI Agents SDK in-process | In-process library |
Tooling / automation
framework | Purpose | Integration Mode |
|---|---|---|
mcp | Call a Model Context Protocol tool server | Local (stdio) or remote server |
zapier | Trigger a Zapier action | Remote service / MCP |
composio | Run a Composio integration | Managed SDK |
n8n | Trigger an n8n workflow | Webhook |
Hosted agent / chatbot platforms
framework | Purpose | Integration Mode |
|---|---|---|
lyzr | Call a Lyzr agent | HTTP service |
writer | Call a Writer (Palmyra) model/agent | HTTP service |
dify | Call a Dify app | HTTP service |
flowise | Call a Flowise chatflow | HTTP service |
vellum | Call a Vellum workflow deployment | HTTP service |
stackai | Call a StackAI flow | HTTP service |
agentforce | Call a Salesforce Agentforce agent | HTTP service |
watsonx | Call an IBM watsonx agent | HTTP service |
relevance | Call a Relevance AI agent | HTTP service |
wordware | Call a Wordware app | HTTP service |
hermes | Call a Hermes agent endpoint | HTTP service |
mistral_agents | Call a Mistral agent (from Mistral AI Studio) | HTTP service |
letta | Call a Letta agent (Letta Cloud or your own server) | HTTP service |
glean_agents | Call a Glean agent (from Glean Agent Builder) | HTTP service |
lindy | Start a Lindy agent through its Webhook Received trigger | HTTP service (callback) |
Managed agent platforms
The agent is built and runs on the vendor's platform. The mesh sends it the input and takes the finished result, keeping one conversation per mesh session and tenant. A run that passes its time limit, or whose mesh session ends, is stopped on the platform where it allows that.
framework | Platform | Required connector_config |
|---|---|---|
claude_managed | Claude Managed Agents | agent_id, environment_id, api_key |
openai_agents_api | OpenAI Agents API | agent_id, api_key |
foundry_agents | Microsoft Foundry Agents | agent_name, and endpoint or resource |
copilot_studio | Microsoft Copilot Studio (Dynamics 365) | token_endpoint or direct_line_secret |
vertex_agent_engine | Google Vertex AI Agent Engine | project, location, engine_id |
bedrock_agentcore | AWS Bedrock AgentCore | agent_runtime_arn |
The required fields for the other hosted agents: mistral_agents needs agent_id and api_key; letta needs agent_id (and api_key for Letta Cloud; base_url for your own server); glean_agents needs agent_id, api_token, and instance or base_url.
Keys, secrets and addresses in these lists (api_key, api_token, endpoint, token_endpoint, direct_line_secret, base_url, webhook_url, webhook_secret) are fields of the agent's record under Infrastructure → External agents, named with connection:; never write them in connector_config.
Lindy answers by calling back, so it uses after_handoff: wait (its
default from 2.4.508; move_on also works). Store the trigger's
URL and its generated secret on a connection (webhook_url, webhook_secret)
and name it with connection:. In your Lindy agent, end the flow with Send
POST Request to Callback, sending back _leafmesh_session_id and
_leafmesh_request_id from the request with your result. With
after_handoff: reply the call only starts the agent.
agents:
lindy_agent:
agent_type: external
framework: lindy
connector_config:
connection: lindy_main # webhook_url + webhook_secret from the vault
after_handoff: wait # 2.4.508 (was mode: callback)Phone-call platforms
2.4.508. An agent can hand a phone call to ElevenLabs Agents, Vapi, Retell or Bland. The platform makes the call and runs the whole conversation; the mesh never touches the audio. When the call is over, the platform's own "call finished" webhook brings the outcome back, and the session moves on through the agent's routes.
framework | Platform | Required connector_config |
|---|---|---|
elevenlabs_agents | ElevenLabs Agents (over Twilio or a SIP trunk: line: twilio or sip_trunk) | agent_id, phone_number_id |
vapi | Vapi | assistant_id, phone_number_id |
retell | Retell AI | from_number (agent_id optional) |
bland | Bland AI | one of pathway_id, persona_id, agent_id or task (from_number optional) |
How to set one up:
- Make the platform's record under Infrastructure → External agents (its
API key, its webhook signing secret and account ids such as
phone_number_id) and name it withconnection:. - Set the platform's webhook address to
<mesh>/callback/calls/<framework>, for examplehttps://mesh.example.com/callback/calls/vapi. Bland is sent the address with each call. The route is signed, so the mesh needsLEAFMESH_WEBHOOK_SECRET. - Give the agent a
phone_numberinput (E.164, e.g.+14155550100). Its other text, number and yes/no inputs become the call's variables; arequest_formadds fixed ones.
agents:
outreach_call:
agent_type: external
framework: vapi
connector_config:
connection: vapi-main # Infrastructure → External agents → Vapi
assistant_id: "<your Vapi assistant id>"
inputs: {phone_number: string}after_handoff is wait (default) or move_on; reply is refused, because a
platform never answers in the reply. callback_timeout defaults to 1800
seconds, since a call rings, talks and is analysed before its webhook comes.
The agent's answer has the same shape on every platform: call_id, status
(completed, no_answer, busy, voicemail or failed), ended_reason,
summary, transcript, data, successful and duration_seconds. A call
that did not happen (no answer, busy, voicemail, failed to connect) is a
failed answer, with the reason in error.
All connectors are included with pip install leafmesh. Composio is a mandatory dependency; framework-specific SDKs (CrewAI, LangGraph, the Claude/OpenAI agent SDKs, etc.) are optional extras (e.g. pip install leafmesh[crewai]); the HTTP/platform connectors need no extra packages.
LeafMesh -- Multi-agent orchestration through managed mesh