Connectors (SaaS)
connectors_saasis the Rust edition's hexagonal SaaS-connector bridge: it turns a vendor catalog (Composio: GitHub, Gmail, Slack, …) into native agent tools through oneSaasBackendport, a statefulSaasGatewayfaçade, a declarative connection state machine, and a pure render layer. Reached asindusagi::connectors_saas::*— a dependency-inverted core never names a vendor SDK; exactly one adapter (Composio, behind thecomposiofeature) speaks the wire.
A coding agent should be able to discover, authorize, and call third-party
products as if they were built-in tools. This module delivers that ports-and-
adapters way: a stateless core (the [SaasBackend] trait plus a small owned
data vocabulary, a content-hash cache, a fluent immutable scope planner, and one
remote-tool builder) holds all the logic, while exactly one adapter translates
the Composio REST API into that vocabulary. The [SaasGateway] façade assembles
the live pieces — a live ToolRegistry, a future-memoizing enable cache, the
connect-rule driver — and hands the agent four high-level control tools
(saas_enable / saas_execute / saas_connect / saas_status) it starts with.
Note: this SaaS connectors module is unrelated to the LLM-provider connector registry inside the gateway subsystem — same word, different subsystem. See Architecture. The TypeScript origin is
connectors-saas/; the Python parity edition is at Connectors.
Table of Contents
- Module map
- Public exports
- The backend port
- The single vendor adapter
- The stateful gateway
- Hydration and the content-hash cache
- The connection state machine
- Scope planning
- The remote-tool builder and control table
- The control surface
- The render layer
- Forbidden I/O seams
- Examples
- Notable behaviors
- Relationship to neighbors
Module map
The module tree mirrors the TypeScript connectors-saas/ subsystem
module-for-module. Declared in connectors_saas/mod.rs:
| Module path | Holds |
|---|---|
connectors_saas::core::port |
The SaasBackend trait (the dependency-inversion PORT) plus the frozen data vocabulary (ToolkitInfo, RemoteTool, RemoteResult, ConnectedAccount, ConnectionRequest, ConnectionState, ConnectionStatus, ExecuteOptions, InitiateOptions, JsonSchema, SaasError, SaasResult). No vendor symbol crosses it. |
connectors_saas::core::cache |
hash_key (sorted-key canonical-JSON SHA-256) and HashCache<V, E>, the future-memoizing store. |
connectors_saas::core::scope_planner |
The fluent, immutable ScopePlanner, its plan() output ResolvedScope, and the ScopeKind tag. |
connectors_saas::core::builder |
build_remote_tool (one catalog op → one DefinedTool), the connector_tools() low-level passthrough table, and core build_control_tools. |
connectors_saas::adapter |
The lone vendor seam: composio_backend (gated behind the composio feature). |
connectors_saas::control::connect |
The connection FSM: the connect_rules() table, pure plan_connect, and the impure initiate_and_await OAuth-poll driver. |
connectors_saas::control::tools |
The Gateway trait, the control_specs() table, and the high-level build_control_tools. |
connectors_saas::render::summarizers |
The summarizer registry + the dedupe-aware default_summarizer. |
connectors_saas::render::format |
The format_* text-block renderers. |
connectors_saas::gateway |
The stateful SaasGateway façade and create_saas_gateway. |
connectors_saas::backends |
forbidden_fs() / forbidden_shell() — the inert I/O seams the gateway's tool context uses. |
The Composio adapter is gated behind a Cargo feature so the core ships SDK-free.
Because the merged reqwest transport is already present (non-optional) for the
other leaf crates, the composio feature is a pure code gate over the adapter
module — it pulls in no extra dependency:
[features]
default = ["rustls", "mcp", "tui"] # note: composio is NOT default
composio = []
full = ["mcp", "tui", "composio", "swarm"]
# build the SaaS adapter in
cargo build -p indusagi --features composio
Public exports
connectors_saas/mod.rs re-exports the bridge's intended import surface. The
stateful façade, the port + its vocabulary, the control surface, the builder, the
cache, the scope planner, and the render layer are all on the module root; the
Composio adapter is re-exported only when the composio feature is on.
| Name | Kind | Source | Purpose |
|---|---|---|---|
create_saas_gateway |
fn | gateway |
Assemble an Arc<SaasGateway> over any Arc<dyn SaasBackend> and a SaasGatewayOptions. |
SaasGateway |
struct | gateway |
The stateful façade: control_tool_box / tool_box / enable_toolkit assembly plus the enable/execute/connect/status verbs. |
SaasGatewayOptions |
struct | gateway |
Tuning knobs: account_id pin and connect (a ConnectTuning). |
ConnectTuning |
struct | gateway |
The pacing/bounding subset of AwaitOptions a host may pre-configure: poll_interval_ms, max_polls, sleep. |
EnableReport |
struct | gateway |
Outcome of hydrating a toolkit: toolkit, hydrated names, tools (Vec<Arc<DefinedTool>>), cached. |
ConnectReport |
struct | gateway |
Terminal outcome of a connect flow: toolkit, action, request_id, auth_url, account_id, reason. |
StatusReport |
struct | gateway |
Snapshot: accounts (Vec<ConnectedAccount>) and enabled_tools (in-scope remote tool names). |
SaasBackend |
trait | core::port |
The dependency-inversion PORT: an async trait of six methods. |
SaasError / SaasResult<T> |
enum / alias | core::port |
The seam error carrying the offending port method, and its Result alias. |
ToolkitInfo |
struct | core::port |
A connectable product: slug, name, description, optional connected. |
RemoteTool |
struct | core::port |
One callable catalog operation: name, toolkit, description, input_schema (JsonSchema). |
RemoteResult |
struct | core::port |
Outcome of running a RemoteTool: ok, data, error, log_id. |
ConnectedAccount |
struct | core::port |
A credential link: id, toolkit, status, updated_at. |
ConnectionRequest |
struct | core::port |
A freshly opened attempt: id, toolkit, status, auth_url. |
ConnectionState |
struct | core::port |
A polled snapshot: id, status, account_id, auth_url. |
ConnectionStatus |
enum | core::port |
Normalized Active/Pending/Expired/Failed the adapter folds the vendor enum into. |
ExecuteOptions / InitiateOptions |
structs | core::port |
Per-call execute (account_id) and connection-open (auth_config_id, callback_url) knobs. |
JsonSchema |
alias | core::port |
serde_json::Value — a JSON-Schema object for tool parameters. |
HashCache / hash_key |
struct / fn | core::cache |
The future-memoizing enable cache and its content-hash key function. |
ScopePlanner / ResolvedScope / ScopeKind |
struct / enum / enum | core::scope_planner |
Fluent immutable scope resolution. |
build_remote_tool / build_control_tools / coin_remote_name |
fns | core::builder |
The single remote-tool builder, the core control-table factory, and the ext:<toolkit>.<slug> name minter. |
connect_rules / plan_connect / initiate_and_await / is_terminal |
fns | control::connect |
The FSM table, the pure planner, the impure driver, and the terminality test. |
AwaitOptions / ConnectAction / ConnectOutcome |
structs / enum | control::connect |
Driver inputs, the five-variant action union, the terminal outcome. |
Gateway |
trait | control::tools |
The four verbs the high-level control tools delegate to (implemented by SaasGateway). |
summarize_result / default_summarizer |
fns | render::summarizers |
One-line result rendering with a pluggable registry and a dedupe-aware fallback. |
format_toolkit_list / format_tool_list / format_connected_accounts / format_connection_request / format_connection_state |
fns | render::format |
Pure data-in / string-out roster, catalog, accounts, and link renderers. |
create_composio_backend / ComposioBackend / ComposioBackendOptions |
fn / struct / struct | adapter::composio_backend |
Build the lone Composio-backed SaasBackend (only with --features composio). |
The backend port
SaasBackend (core::port) is the dependency-inversion seam — the only thing
core logic depends on. It is an #[async_trait] trait so the gateway can hold an
Arc<dyn SaasBackend>, and its six methods cover the three jobs the bridge does:
#[async_trait]
pub trait SaasBackend: Send + Sync {
// discovery
async fn list_toolkits(&self) -> SaasResult<Vec<ToolkitInfo>>;
async fn list_tools(&self, toolkit: &str) -> SaasResult<Vec<RemoteTool>>;
// execution
async fn execute(&self, tool_name: &str, args: Value, opts: ExecuteOptions)
-> SaasResult<RemoteResult>;
// linking / auth
async fn list_connected_accounts(&self) -> SaasResult<Vec<ConnectedAccount>>;
async fn initiate_connection(&self, toolkit: &str, opts: InitiateOptions)
-> SaasResult<ConnectionRequest>;
async fn check_connection(&self, id: &str) -> SaasResult<ConnectionState>;
}
Every shape it traffics in is a derive-Serialize/Deserialize struct or enum
owned by the bridge. Rust's Option<T> models the TS optional fields; absence is
None, with #[serde(skip_serializing_if = "Option::is_none")] so an omitted
field round-trips faithfully. Wire-name parity is preserved with explicit
#[serde(rename = "…")] (inputSchema, authUrl, accountId, updatedAt,
logId, authConfigId, callbackUrl).
ConnectionStatus is the normalized closed enum — Active, Pending,
Expired, Failed (#[serde(rename_all = "lowercase")]) — so core logic only
ever branches on a four-member set it owns. The adapter folds the catalog's
richer enum down to these buckets.
pub type JsonSchema = Value; // a JSON-Schema object
pub type SaasResult<T> = Result<T, SaasError>;
#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)]
pub enum SaasError {
#[error("{message}")]
Backend { method: String, message: String },
}
SaasError::backend(method, message) mirrors the TS
new ComposioBackendError(method, message); SaasError::method() returns the
port method that could not be satisfied. The contract is that execute never
surfaces an Err for operational failures — those become a
RemoteResult { ok: false } — so an Err is reserved for true plumbing faults
at the seam. Because the port is a trait, a test backend is just a small struct
implementing it; no SDK install required (see the fake-backend example below).
The single vendor adapter
ComposioBackend (adapter::composio_backend, gated #[cfg(feature = "composio")]) is the lone implementation of the port and the only place a
vendor catalog leaks in. There is no official Composio Rust SDK, so the adapter
speaks the Composio REST API directly via reqwest, re-deriving the six method
mappings and folding the vendor's snake-/camel-cased shapes down to the port
vocabulary. The base is COMPOSIO_BASE = "https://backend.composio.dev/api"; the
default user scope is DEFAULT_USER = "default".
It is constructed from a ComposioBackendOptions { api_key: String, user_id: Option<String> } — the API key the client authenticates with, plus the
single-user scope the catalog is queried under (blank/absent user_id falls
back to "default"). The struct is pub but the public constructor is
create_composio_backend(api_key), which leaves user_id unset.
| Port method | Composio REST call |
|---|---|
list_toolkits |
GET /v3/toolkits |
list_tools |
GET /v3/tools?toolkits=<slug> |
execute |
POST /v3/tools/execute/<slug> (body { userId, arguments, connectedAccountId? }) |
list_connected_accounts |
GET /v3/connected_accounts?userIds=<user> |
initiate_connection |
POST /v3/connected_accounts/link (body { userId, toolkit, authConfigId?, callbackUrl? }) |
check_connection |
GET /v3/connected_accounts/<id> |
Requests carry the key as an x-api-key header; the query string is encoded by
hand (encode_component keeps the RFC-3986 unreserved set, escapes the rest) so
the adapter needs no extra reqwest feature. get_json/post_json chain
error_for_status and fold any reqwest error into a SaasError::backend tagged
with the transport method ("composio.get" / "composio.post"), passing the
reqwest error string through error_message. Argument-guard failures (a blank
slug / request id) instead surface a SaasError::backend tagged with the port
method name ("listTools", "initiateConnection", "checkConnection").
Internal list responses are unwrapped by list_items (a bare array, else the
array nested under items). Vendor shapes are re-narrowed by pure projector
functions before anything untyped escapes:
| Projector | Folds | Notes |
|---|---|---|
normalize_status(&str) |
vendor status → ConnectionStatus |
ACTIVE→Active; INITIALIZING/INITIATED→Pending; EXPIRED→Expired; FAILED/INACTIVE/REVOKED→Failed; unknown→Pending (never silently active). |
pick_auth_url(&Value) |
redirectUrl (camel) then redirect_url (snake) → Option<String> |
The actionable redirect URL. |
to_toolkit_info(&Value) |
raw item → ToolkitInfo |
description from meta.description then top-level. |
to_remote_tool(&Value, hint) |
raw catalog tool → RemoteTool |
inputParameters → input_schema, else an empty object schema. |
to_connected_account(&Value) |
raw record → ConnectedAccount |
accepts updatedAt/updated_at, toolkit.slug/toolkitSlug; missing status → Pending. |
clean_string(Option<&Value>) |
a JSON field → Option<String> |
trim, drop blank/non-string. The workhorse every other projector leans on. |
error_message(&str) |
error string → tidy line | trims; blank falls back to "the Composio SDK reported an unspecified error". |
The execute mapping reads the vendor response by field: successful == true
sets RemoteResult.ok, the data key becomes the payload, logId becomes
log_id, and on a non-success the error key (or a synthesized
"<slug> reported a failure") becomes error. A blank slug short-circuits to a
flagged RemoteResult rather than calling the wire. When post_json itself
returns an Err, that fault is folded to RemoteResult { ok: false, error: Some(error_message(err.method())) } — note the surfaced error line is the seam
method tag (e.g. "composio.post"), not the underlying transport message.
create_composio_backend(api_key) is keyword-shaped via impl Into<String>,
trims the key, and returns Err(SaasError::backend("createComposioBackend", …))
if it is blank; otherwise it returns an Arc<dyn SaasBackend>.
# #[cfg(feature = "composio")]
use indusagi::connectors_saas::create_composio_backend;
# #[cfg(feature = "composio")]
let backend = create_composio_backend("comp_...")?; // Arc<dyn SaasBackend>
# Ok::<(), indusagi::connectors_saas::SaasError>(())
The stateful gateway
create_saas_gateway(backend, opts) returns an Arc<SaasGateway> — the concrete
façade that owns the state the stateless core deliberately lacks:
pub struct SaasGateway {
backend: Arc<dyn SaasBackend>,
account_id: Option<String>,
connect_opts: ConnectTuning,
control_tools: OnceLock<Vec<Arc<DefinedTool>>>,
remote_tools: Mutex<IndexMap<String, Arc<DefinedTool>>>,
enable_cache: HashCache<Vec<Arc<DefinedTool>>, SaasError>,
self_weak: OnceLock<Weak<SaasGateway>>,
}
- a live tool set (
remote_tools, anIndexMapso iteration mirrors the JSMaporder used bytool_box()andstatus().enabled_tools), - the four control tools, built once via
OnceLockand registered up front socontrol_tool_box()works before any toolkit is hydrated, - a
HashCache<Vec<Arc<DefinedTool>>, SaasError>of enable results, - a
self_weakWeak<SaasGateway>set right after construction so theGateway-trait re-entry (which arrives as&self) can recover theself: &Arc<Self>receiver the hydration path needs — without a reference cycle.
It exposes three assembly methods and four verbs (the verbs are surfaced both as
inherent methods and through the Gateway trait the control tools call):
| Method | Returns | Purpose |
|---|---|---|
control_tool_box() |
ToolBox |
Just the four control tools — the surface an agent starts with. |
tool_box() |
ToolBox |
The control tools plus every hydrated remote tool. |
enable_toolkit(name, only) |
Result<Vec<Arc<DefinedTool>>, SaasError> |
Hydrate a toolkit into the live registry; content-hash cached. |
enable_report(toolkit, only) |
EnableReport |
The verb behind saas_enable (reads cached before hydrating). |
execute_op(tool, args, account_id) |
ToolResult |
The verb behind saas_execute — run one operation by slug. |
connect_flow(toolkit, callback_url, auth_config_id) |
ConnectReport |
The verb behind saas_connect — drive auth to a terminal state. |
status_report() |
StatusReport |
The verb behind saas_status. |
backend() |
&Arc<dyn SaasBackend> |
Borrow the underlying port (an accessor, not a verb). |
Each tool box is built by box_of, which registers the tools into a scratch
ToolRegistry and boxes it with a ContextFactory of inert_context — a
tail-windowed (OutputBudget::new(ClipEnd::Tail, 1 << 20)), never-aborting
(CancellationToken::new()) context whose fs/shell are the forbidden seams
(see Forbidden I/O seams). The SaaS tools route entirely
through the port and never touch the filesystem, so a future tool that does fails
loudly.
SaasGatewayOptions carries an account_id pin and an optional ConnectTuning
(poll_interval_ms, max_polls, sleep). Only the pacing fields are stored on
the gateway; auth_config_id/callback_url always come from the per-call
connect_flow(...) arguments.
Hydration and the content-hash cache
Hydration is the act of turning a toolkit's remote operations into native
tools. enable_toolkit normalizes the request into an EnableShape
({ toolkit, only } with only planner-normalized — trimmed, deduped, sorted),
runs it through the cache, lists the scoped operations, mints one DefinedTool
per op via build_remote_tool, and registers them by name. Hydrated tools are
named ext:<toolkit>.<slug> by coin_remote_name so they never collide with
built-ins and their origin is obvious in a tool list.
The headline invariant is identity-stable hydration: enabling the same
toolkit twice calls backend.list_tools exactly once and returns the same
Arc<DefinedTool> identities — the second enable is served from the cache. This
follows from how HashCache<V, E> works (core::cache):
pub struct HashCache<V: Clone + Send + Sync + 'static,
E: Send + Sync + 'static = SaasError> { /* slots */ }
pub async fn take<F>(&self, shape: &Value, factory: F) -> Result<V, Arc<E>>
where F: FnOnce() -> BoxFuture<'static, Result<V, Arc<E>>>;
The cache keys on hash_key(shape) and memoizes in-flight futures, not
values: it stores a futures::future::Shared<BoxFuture<…>>, so concurrent
callers asking for the same shape share one backing computation and every awaiter
observes the identical resolved V (the same Arcs for V = Vec<Arc<DefinedTool>>). On a settled Err, a chained inspect evicts the slot
so a later call can retry rather than re-serving the failure forever; a cancelled
caller dropping its clone never poisons the shared computation. has /
drop_slot / clear / size round out the store.
hash_key is canonicalization-parity-faithful with the TS: it canonicalizes
the value (sorting object keys with a string sort, keeping array order, passing
scalars through), encodes it with the core's character-faithful
canonical_json, and takes the full 64-char hex
SHA-256 digest (not the truncated content_hash):
pub fn hash_key(value: &Value) -> String {
let canonical = canonical_json(&canonicalize(value));
hex::encode(Sha256::digest(canonical.as_bytes()))
}
EnableReport.cached is read before the (possibly cache-filling) enable, via
enable_cache.has(shape), exactly mirroring the TS ordering — so a first enable
reports cached: false and an identical repeat reports true.
The connection state machine
control::connect expresses the OAuth-style link flow as policy-as-data.
connect_rules() returns a [ConnectRule; 4] — exactly one row per
ConnectionStatus member, so the matrix is total by construction. Each row pairs
a when: ConnectionStatus with a decide: fn(&ConnectionState) -> ConnectAction.
plan_connect(state) is pure — state in, action out, no I/O — consulting the
rule table; the closed enum makes the match total. ConnectAction is a five-
variant union, serialized with #[serde(tag = "kind", rename_all = "kebab-case")]:
| Variant | Meaning | Terminal? |
|---|---|---|
AwaitAuth { auth_url } |
The user must visit auth_url; keep polling. |
no |
Poll |
Progressing on the backend; check again shortly. | no |
Done { account_id } |
The link is active; the account id is ready. | yes |
Expired |
The attempt lapsed; the caller may re-initiate. | yes |
Failed { reason } |
The attempt terminally failed. | yes |
is_terminal returns true for the last three.
initiate_and_await(backend, toolkit, options) is the one impure driver. It
opens a connection once, then loops up to max_polls: consult plan_connect,
track last_auth_url, return on a terminal action (checked before the sleep),
else sleep_for(...) and check_connection again. Exhausting the budget yields
a Failed outcome ("connection did not become active within N checks") rather
than hanging. It does not re-initiate on expired — that decision is left to
the caller.
AwaitOptions carries auth_config_id, callback_url, poll_interval_ms
(default 1500), max_polls (default 40), and an injectable sleep: Option<SleepFn>:
pub type SleepFn =
Arc<dyn Fn(Duration) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync>;
When sleep is None, the driver falls back to tokio::time::sleep; injecting
a custom SleepFn lets tests drive the loop without real time passing. The
gateway's connect_flow maps the terminal ConnectAction onto a ConnectReport
via report_action (Poll/AwaitAuth → "await-auth", others pass through),
and surfaces a seam fault opening the connection as a "failed" report carrying
the error line.
Scope planning
ScopePlanner (core::scope_planner) is a fluent, immutable builder — every
mutator returns a new instance. Start with ScopePlanner::create(), narrow with
include(toolkits) and only(tool_names), resolve once with plan(), and probe
with is_empty() (true until something is declared). Both mutators normalize
their accumulated intent through a BTreeSet<String> — trim, drop blanks, dedup,
sort — in one step.
plan() resolves into one of three ResolvedScope shapes (no nested branching),
each tagged by kind() → ScopeKind:
ByTools { tools, toolkits }— pinned tool slugs win (the most specific intent; the included toolkits ride along as context),ByToolkits { toolkits }— one or more whole toolkits in scope,Empty— nothing declared.
The gateway uses the planner inside normalize_pins to canonicalize an enable
request's pinned ops into the sorted, deduped EnableShape.only before
content-hashing it — which is why two differently-ordered pin lists hash to the
same enable cache slot.
The remote-tool builder and control table
core::builder holds the one place a catalog operation becomes a native
tool, plus the low-level control passthrough table — never a family of
near-identical factories.
build_remote_tool(reference, backend, account_id) mints a DefinedTool that
advertises the operation's own input_schema and, on invocation, forwards the
parsed arguments to backend.execute(ref.name, input, { account_id }), folding
the RemoteResult onto a ToolResult via render_remote_result (success → a
status line plus a JSON content block of the payload; failure → a single flagged
error line; is_error mirrors result.ok). All tool behavior is captured in a
single ClosureTool adapter (Tool whose run is a boxed async closure), so
neither the remote builder nor either control table needs a bespoke Tool impl
per operation:
pub fn build_remote_tool(
reference: RemoteTool,
backend: Arc<dyn SaasBackend>,
account_id: Option<String>,
) -> DefinedTool;
pub fn coin_remote_name(reference: &RemoteTool) -> String; // "ext:<toolkit>.<name>"
The namespace prefix is the public constant REMOTE_PREFIX = "ext" (re-exported
from core); coin_remote_name preserves toolkit/slug casing verbatim. The
single ClosureTool adapter is fed by small loose-argument readers — read_str
(a trimmed non-empty string field, pub(crate)) and the public
read_string_list (a string array, dropping non-strings/blanks) — so both
control tables parse their Value input the same way. render_remote_result
turns a RemoteResult into a ToolResult: success → a "<slug> completed." line
plus a JSON block of the payload (omitted when data is None); failure →
"<slug> failed: <why>" flagged is_error.
connector_tools() returns the four low-level passthrough tools as a declarative
Vec<ControlToolSpec> — id, description, parameters: fn() -> JsonSchema,
and a run: fn(Arc<dyn SaasBackend>, Value) -> BoxFuture<…> that routes straight
through the bare port: saas.attach (list a toolkit's ops), saas.run (execute
by slug), saas.link (open a connection), saas.status (list accounts or poll a
request id). The core build_control_tools(backend) maps the table into
DefinedTools. This is a distinct surface from the high-level control tools
(see below) — note the dotted names (saas.attach) versus the underscored
gateway-facing ones (saas_enable).
The control surface
control::tools defines the four high-level control tools an agent uses to
manage SaaS connectors as a whole. They delegate to the Gateway trait rather
than reimplement logic:
#[async_trait::async_trait]
pub trait Gateway: Send + Sync {
async fn enable(&self, toolkit: &str, only: Option<Vec<String>>) -> EnableReport;
async fn execute(&self, tool: &str, args: Value, account_id: Option<String>) -> ToolResult;
async fn connect(&self, toolkit: &str, callback_url: Option<String>,
auth_config_id: Option<String>) -> ConnectReport;
async fn status(&self) -> StatusReport;
}
control_specs() is the declarative table — one ControlSpec row per tool
(id, description, parameters, and a run: fn(Arc<dyn Gateway>, Value) -> BoxFuture<…>) — and build_control_tools(gateway) maps it into DefinedTools,
one ClosureTool per row. SaasGateway implements Gateway (recovering its
owning Arc<Self> through self_weak for the hydration path), so each verb
forwards to the inherent method:
| Tool | Verb | Result rendering |
|---|---|---|
saas_enable |
Gateway::enable |
render_enable — "Enabled N tool(s) from …" (or "No tools were enabled from …" when nothing hydrated) + the EnableReport JSON. |
saas_execute |
Gateway::execute |
the raw ToolResult from execute_op. |
saas_connect |
Gateway::connect |
render_connect — an actionable line + the ConnectReport JSON (is_error only when action == "failed"). |
saas_status |
Gateway::status |
"N connected account(s); M operation(s) in scope." + the StatusReport JSON. |
ConnectReport::to_json is parity-critical on field presence: authUrl only
when present, accountId only when action == "done" and present, reason only
when action == "failed".
The render layer
render/ is pure presentation — no I/O, no vendor SDK.
render::summarizers turns a RemoteResult into one short line. summarizers()
is a process-wide &'static HashMap<&str, Summarizer> (a OnceLock), seeded
with collection-counting rows for github, gmail, and slack. A Summarizer
is an Arc<dyn Fn(&RemoteResult) -> String + Send + Sync>. summarize_result
selects most-specific-first: an exact toolkit-slug entry, then the longest
matching prefix:* glob, then default_summarizer. toolkit_of extracts the
lower-cased toolkit slug implied by a <toolkit>_<op> or <toolkit>.<op> name.
default_summarizer renders a failure as error: <line>, and a success as a
dedupe-aware payload preview (ok: <preview>). The preview_payload walk is
iterative and breadth-first (an explicit VecDeque used as a FIFO —
push_back / pop_front, never recursion, so a deep or cyclic response cannot
blow the stack). Every container is content-hashed via hash_key: the first
sighting emits #i plus a tag_of tag, any later container with the same hash
collapses to a =#i back-reference. Each node's tag_of shows only its
immediate shape — objects as {key,key,…} (first 6 keys, keys only), arrays as
[n], scalars as their JSON (numbers via the core encoder). Children enqueue
arrays in order and objects by sorted key; the walk caps at MAX_NODES (24)
distinct nodes, clamp_line trims the whole line to MAX_LINE (200) chars with a
trailing …, and an un-drained queue appends a … marker. The seeded toolkit
rows (via count_summarizer) prepend a count_hint (a bare array → "N item(s)";
else the first of items|data|results|messages|issues holding an array → "N
render::format exposes pure data-in / string-out renderers, each returning a
single \n-joined text block with a status glyph per ConnectionStatus
(● ◐ ○ ✗):
| Function | Renders |
|---|---|
format_toolkit_list(&[ToolkitInfo]) |
the connectable-toolkit roster |
format_tool_list(&[RemoteTool]) |
one toolkit's operation catalog |
format_connected_accounts(&[ConnectedAccount]) |
the user's credential links |
format_connection_request(&ConnectionRequest) |
a freshly opened link |
format_connection_state(&ConnectionState) |
a polled link snapshot |
Forbidden I/O seams
connectors_saas::backends provides forbidden_fs() and forbidden_shell()
(both pub(crate) — an internal seam, not part of the public export surface) —
an Arc<dyn Fs> and an Arc<dyn Shell> backed by zero-sized ForbiddenFs /
ForbiddenShell structs whose every fallible method returns io::Error::other(…)
("SaaS tools have no access to the filesystem." / "… to the shell."); the one
infallible probe, Fs::exists, simply answers false. The gateway's
inert_context installs them so any SaaS tool that reaches for ctx.fs or
ctx.shell fails loudly instead of silently mis-operating. This is the Rust
analogue of the TS forbidden<T> Proxy.
Examples
Wire a gateway to Composio and hand its control tools to an agent
use indusagi::connectors_saas::{
create_saas_gateway, create_composio_backend, SaasGatewayOptions,
};
let backend = create_composio_backend("comp_...")?; // requires --features composio
let gateway = create_saas_gateway(backend, SaasGatewayOptions::default());
// Before any toolkit is hydrated, the agent only sees the four control tools:
let box_ = gateway.control_tool_box(); // saas_enable / saas_execute / saas_connect / saas_status
// Drive an OAuth flow to a terminal state:
let report = gateway.connect_flow("github", None, None).await;
match report.action.as_str() {
"await-auth" => println!("Authorize here: {:?}", report.auth_url),
"done" => println!("linked account: {:?}", report.account_id),
_ => println!("{}", report.reason.unwrap_or_default()),
}
# Ok::<(), indusagi::connectors_saas::SaasError>(())
Hydrate a toolkit into native tools (content-hash cached)
let pins = ["GITHUB_CREATE_ISSUE".to_string()];
let enable = gateway.enable_report("github", Some(&pins)).await;
println!("{:?}", enable.hydrated); // ["ext:github.GITHUB_CREATE_ISSUE"]
println!("{}", enable.cached); // false the first time, true on an identical repeat
// tool_box() now carries the control tools PLUS the hydrated remote tools:
let full_box = gateway.tool_box();
// Or run an operation directly without hydrating it first:
let result = gateway
.execute_op("GITHUB_CREATE_ISSUE", serde_json::json!({ "repo": "o/r", "title": "hi" }), None)
.await;
Implement a fake `SaasBackend` for tests (no vendor SDK)
Any struct implementing the trait satisfies the port — no Composio feature, no network:
use std::sync::Arc;
use async_trait::async_trait;
use serde_json::Value;
use indusagi::connectors_saas::{
SaasBackend, SaasResult, ToolkitInfo, RemoteTool, RemoteResult,
ConnectedAccount, ConnectionRequest, ConnectionState, ConnectionStatus,
ExecuteOptions, InitiateOptions, create_saas_gateway, SaasGatewayOptions,
};
struct FakeBackend;
#[async_trait]
impl SaasBackend for FakeBackend {
async fn list_toolkits(&self) -> SaasResult<Vec<ToolkitInfo>> {
Ok(vec![ToolkitInfo {
slug: "gh".into(), name: "GitHub".into(),
description: "git".into(), connected: None,
}])
}
async fn list_tools(&self, _toolkit: &str) -> SaasResult<Vec<RemoteTool>> {
Ok(vec![RemoteTool {
name: "GH_X".into(), toolkit: "gh".into(),
description: "do x".into(),
input_schema: serde_json::json!({ "type": "object", "properties": {} }),
}])
}
async fn execute(&self, _t: &str, args: Value, _o: ExecuteOptions) -> SaasResult<RemoteResult> {
Ok(RemoteResult { ok: true, data: Some(serde_json::json!({ "echo": args })), ..Default::default() })
}
async fn list_connected_accounts(&self) -> SaasResult<Vec<ConnectedAccount>> {
Ok(vec![ConnectedAccount {
id: "a1".into(), toolkit: "gh".into(),
status: ConnectionStatus::Active, updated_at: None,
}])
}
async fn initiate_connection(&self, toolkit: &str, _o: InitiateOptions) -> SaasResult<ConnectionRequest> {
Ok(ConnectionRequest {
id: "r1".into(), toolkit: toolkit.into(),
status: ConnectionStatus::Pending, auth_url: Some("https://x".into()),
})
}
async fn check_connection(&self, id: &str) -> SaasResult<ConnectionState> {
Ok(ConnectionState {
id: id.into(), status: ConnectionStatus::Active,
account_id: Some("a1".into()), auth_url: None,
})
}
}
# async fn demo() {
let gateway = create_saas_gateway(Arc::new(FakeBackend), SaasGatewayOptions::default());
let status = gateway.status_report().await; // StatusReport { accounts: [...], enabled_tools: [] }
# let _ = status;
# }
Plan scope, summarize a result, and format a roster (pure helpers)
use indusagi::connectors_saas::{
ScopePlanner, ResolvedScope, summarize_result, format_toolkit_list,
RemoteResult, ToolkitInfo,
};
let scope = ScopePlanner::create()
.include(["github"])
.only(["GH_CREATE", "GH_LIST"])
.plan();
assert!(matches!(scope, ResolvedScope::ByTools { .. }));
let line = summarize_result(
"github_list_issues",
&RemoteResult { ok: true, data: Some(serde_json::json!({ "issues": [1, 2, 3] })), ..Default::default() },
);
println!("{line}"); // "github ok: 3 issues"
println!("{}", format_toolkit_list(&[ToolkitInfo {
slug: "gh".into(), name: "GitHub".into(),
description: "git host".into(), connected: Some(true),
}]));
Notable behaviors
- Two functions named
build_control_tools.core::builder's takes anArc<dyn SaasBackend>and builds the low-level passthrough tools (saas.attach/saas.run/saas.link/saas.status, dotted).control::tools's takes anArc<dyn Gateway>and builds the four agent-facing control tools (saas_enable/saas_execute/saas_connect/saas_status, underscored). The gateway uses the latter; the dotted core set is a separate passthrough surface. Both are re-exported from the module root. - Feature-gated, dependency-free adapter. The
composiofeature is a pure code gate over the adapter module —reqwestis already present. With the feature off,create_composio_backend/ComposioBackendsimply do not exist; the rest of the bridge compiles and runs against any customSaasBackend. - Identity-stable hydration. A repeat
enableof the same shape returns the identicalArc<DefinedTool>references and re-lists nothing — the cache stores aSharedfuture ofVec<Arc<DefinedTool>>, and registration is idempotent by tool name. - Failures surface as flagged results, not errors. The adapter's
executefolds a transportErrintoRemoteResult { ok: false }whoseerrorline is the seam method tag (error_message(err.method()), e.g."composio.post"), and asuccessful != trueresponse into anok: falsecarrying the vendorerrorkey;execute_opand the builder then collapse any of these to a single flaggedToolResult, so the model boundary sees a flagged result rather than a raised error. (Genuinely unsatisfiable requests — a blank toolkit slug or request id — still raiseErr(SaasError::backend)from the discovery/link methods.) - Adapter identity quirk. The Composio adapter treats the connection-request
id and the connected-account id as the same handle (
check_connectionpollsGET /v3/connected_accounts/<id>). - Status normalization is conservative.
normalize_statusmapsINACTIVE/REVOKEDtoFailedand any unknown vendor state toPending— never silentlyActive. - Inert, forbidden context. Every gateway-issued tool box dispatches through
a never-aborting, tail-windowed context whose
fs/shelldeny all access.
Relationship to neighbors
The bridge builds on the capabilities kernel for the tool surface (DefinedTool,
Tool, ToolContext, ToolResult, ToolContentBlock, ToolRegistry,
ToolBox, OutputBudget, ClipEnd, Framework, define_tool, plus the Fs /
Shell seams), on core for canonical_json (the
content-hash key encoder) and CancellationToken, and on reqwest for the lone
Composio transport. It is one of the SaaS-facing leaves of the
Architecture stack, the Rust parity port of the TypeScript
connectors-saas/ subsystem and the Python
Connectors edition.
