tulip-frameworks
Put Tulip's control gate around the actions your existing agent already takes — without rebuilding it on Tulip.
Tulip is an open-source agentic harness — the
control runtime for agents that act: a consequential action runs only after your
policy clears it, waits for a person when it matters, and lands on a record you
can prove. tulip-frameworks lets you keep the agent you already have — in
LangChain, LangGraph, CrewAI, the OpenAI Agents SDK, LlamaIndex, or Google
ADK — and bring just that control layer to the tools it calls, without
rebuilding on the full Tulip SDK.
You wrap a tool once. From then on, when the agent decides to refund an order, disable an account, or run a deploy, the gate decides whether that action is allowed, held for a human, or denied — and writes the decision down either way.
Why this exists
An agent that can only read is easy to trust. An agent that can act is not: the
thing deciding is a language model, and a bad retrieval, a confused chain of
thought, or a prompt injection can end with refund(order, 1_000_000). A system
prompt is a guideline the model can ignore. Tulip's answer is a check in real
code, outside the model, between the decision and the side effect — the model
can be fooled; the action still doesn't execute unless your policy allows.
tulip-frameworks is how you get that check without leaving the framework you
already build in.
Install
pip install "tulip-frameworks[langchain]" # LangChain
pip install "tulip-frameworks[langgraph]" # LangGraph
pip install "tulip-frameworks[openai-agents]" # OpenAI Agents SDK
pip install "tulip-frameworks[crewai]" # CrewAI
pip install "tulip-frameworks[llama-index]" # LlamaIndex
pip install "tulip-frameworks[adk]" # Google ADK
pip install "tulip-frameworks[all]" # everything
import tulip_frameworks pulls in no framework package. Each bridge imports its
framework lazily and, if it's missing, tells you exactly which extra to install.
Quickstart — LangChain
from langchain_core.tools import tool
from tulip.control import Action, AuditTrail
from tulip_frameworks.langchain import gate_langchain_tool
from tulip_frameworks.policy_presets import action_gate_policy
@tool
def refund(order_id: str, amount_usd: float) -> str:
"Issue a customer refund."
return payments.refund(order_id, amount_usd)
trail = AuditTrail()
safe_refund = gate_langchain_tool(
refund,
# Describe the action's risk from the call's arguments.
action=lambda name, a: Action(
name=name, asset=a["order_id"],
blast_radius=1, kind="payment", environment="production",
),
policy=action_gate_policy(), # production actions → held for a human
trail=trail,
)
# Drop it into the agent exactly where `refund` went — same name, schema, description.
# agent = create_react_agent(model, tools=[safe_refund])
safe_refund is a real LangChain StructuredTool. Give it to your agent in place
of refund. Now:
- A refund in a non-production environment runs and is recorded.
- A production refund is held for a human — the function never executes; the agent gets back a structured "held for approval" result it can act on.
- Every decision lands on
trail, which you cantrail.verify()(tamper-evident — editing any record breaks verification) andtrail.export_jsonl()(ship to a SIEM — a security team's log platform — or a warehouse).
Whatever leads the model to attempt a thousand production refunds — a bad retrieval, a runaway loop, or a prompt injection — the result is a thousand held actions and zero executed ones.
The mental model
Every bridge is a thin wrapper over one primitive — gate_callable — which is the
only thing that actually calls the core SDK's admit(). Three inputs decide what
happens:
| Input | What it is | Who supplies it |
|---|---|---|
| Action | What the agent is about to do — its asset, blast_radius, environment, kind, tags. |
You, as a constant Action or a (name, kwargs) -> Action callable that reads the call's arguments. |
| Policy | The rule. action_gate_policy() gates on labels + blast radius; or bring a full ControlPolicy. |
You. |
| Trail | Where decisions are recorded, hash-chained so tampering is detectable. | Optional AuditTrail. |
The gate's output is one of three outcomes — allow, require_human, or deny — and the side effect runs only on allow. The audit record is written before a hold or denial surfaces, so a held action can never slip through as an un-recorded side effect.
Deriving the Action from the arguments is the recommended form, because risk
usually depends on them — a refund of $5 in staging is not the same action as a
refund of $50,000 in production.
Every framework, one line
The shape is identical across frameworks: pass the tool you already have, an
action, and a policy; get back a gated tool in that framework's native type.
from tulip_frameworks.langchain import gate_langchain_tool # -> StructuredTool
from tulip_frameworks.langgraph import gate_langchain_tool, gate_node
from tulip_frameworks.openai_agents import gate_openai_tool # -> FunctionTool
from tulip_frameworks.crewai import gate_crewai_tool # -> BaseTool
from tulip_frameworks.llamaindex import gate_llamaindex_tool # -> FunctionTool
from tulip_frameworks.adk import gate_adk_tool # -> FunctionTool
- LangChain —
gate_langchain_tool(tool, …)returns aStructuredToolwith the same name, description, and args schema. Drop-in. - LangGraph —
ToolNodeconsumes a gated LangChain tool unchanged, sogate_langchain_toolis re-exported here. For a raw graph node that performs a side effect directly, usegate_node(node_fn, name=…, action=…, policy=…); itsactionreads the graphstate. - OpenAI Agents SDK —
gate_openai_tool(function_tool, …)returns aFunctionToolthat drops intoAgent(tools=[…]). - CrewAI —
gate_crewai_tool(tool, …)returns aBaseTool. CrewAI runs tools synchronously; the bridge drives the async gate for you. - LlamaIndex —
gate_llamaindex_tool(tool, …)returns aFunctionTool. - Google ADK —
gate_adk_tool(tool_or_fn, …)returns aFunctionTooland preserves the original signature, so ADK builds the right function declaration.
Held or raised — your choice
When an action doesn't admit, you pick how it surfaces with mode:
-
mode="soft"(default) — the gated call returns a structured held-for-approval result the agent loop can read and react to (explain to the user, try a safer path, poll for the human decision). The run stays alive. For LLM-facing bridges the result is JSON, because a tool result has to be a string the model can see.{"status": "held_for_approval", "outcome": "require_human", "action": "refund", "asset": "ord-9", "reason": "production action needs a human"}
-
mode="raise"— the gate re-raisesAdmissionErrorto stop a deterministic pipeline cold.
Either way, the decision is on the trail first.
Out-of-band approval
A held action can be routed to a human on a side channel the agent can't reach.
Pass an ApprovalBridge and the held result carries an approval_id the agent can
poll while a person approves or denies elsewhere:
safe_refund = gate_langchain_tool(refund, action=…, policy=…, trail=trail,
approval=my_bridge)
# held result now includes: "approval_id": "appr-123",
# "next": "call approval_status(approval_id) once a human decides"
ApprovalBridge is a small structural Protocol (submit + state) with no
import-time dependency on any broker. The tulip-gateway
approval broker satisfies it; gateway_bridge(broker) adapts one when you have it —
that adapter wraps a broker object in your process. For a gate on the other side
of the network, see below.
The gate as a plain network call
By default the decision is taken in your process. Swap policy= for a RemotePolicy
and it moves to a gateway instead — the wrapper marshals the Action, POST /v1/admit
weighs it against a server-side policy, and the side effect runs locally only on
ALLOW. Nothing else about your agent changes:
from tulip_frameworks.gateway import RemotePolicy
policy = RemotePolicy("http://gateway:8420", policy_ref="default",
principal="agent:support", tenant="acme")
safe_refund = gate_langchain_tool(refund, action=…, policy=policy, trail=trail)
Every bridge accepts either kind (GatePolicy = ControlPolicy | RemotePolicy). The
held result then carries the gateway's approval_id, and the lifecycle is driven
over the same connection:
await policy.approval_state(approval_id) # pending / approved / denied / consumed
await policy.consume(approval_id) # single-use claim; False if already used
No policy logic ships from this side — policy_ref names a policy the gateway holds —
and the transport is stdlib urllib, so this adds no dependency. Pass transport= to
supply your own (custom auth, proxies, an HTTP client you already have).
The gateway authenticates
/v1/admitwhenTULIP_GATEWAY_AUTH_REQUIREDis on (passtoken=toRemotePolicy), and with auth on the deciding identity is the verified caller — a body-supplieddecided_bycannot be spoofed onto a decision. Tenancy travels as the gateway's ownX-Tulip-Tenantheader (resolved from the token in authenticated deployments), never as a caller-controlled query param.
Claude Code — pip install is the integration
The package installs a tulip-claude-gate console script that speaks Claude
Code's PreToolUse hook contract. Wire it into .claude/settings.json and
every tool call Claude Code proposes is weighed by the gateway's /v1/admit
before it runs, with the decision recorded on the same server-side policy
and tamper-evident chain as every other agent:
{
"hooks": {
"PreToolUse": [
{"matcher": "*",
"hooks": [{"type": "command", "command": "tulip-claude-gate"}]}
]
}
}
Configure with environment variables: TULIP_GATEWAY_URL (required),
TULIP_GATEWAY_TOKEN, TULIP_TENANT, TULIP_POLICY_REF, TULIP_PRINCIPAL,
TULIP_ENVIRONMENT. ALLOW passes the call through; DENY blocks it with the
policy's reason on screen; REQUIRE_HUMAN maps to Claude Code's own permission
prompt — the human-approval step, with the gateway's approval_id in the
reason. If the gateway cannot render a decision the hook fails closed
(set TULIP_FAIL_OPEN=1 to choose otherwise): the dangerous actions must
never be the ones that slip through by accident.
Test your gate offline — no LLM, no network
tulip_frameworks.testing lets you assert a tool admits or holds deterministically,
without running a model:
from tulip_frameworks import gate_callable, action_gate_policy
from tulip_frameworks.testing import Spy, assert_allowed, assert_held
from tulip.control import Action
async def test_production_refund_is_held():
spy = Spy() # stands in for the real side effect
gated = gate_callable(
spy, name="refund",
action=Action(name="refund", environment="production", kind="payment"),
policy=action_gate_policy(),
)
result = await gated(order_id="ord-9")
assert_held(result, spy) # the side effect never ran; result is "held"
This is the same pattern the package's own unit tests use, so the behaviour you assert in CI is the behaviour your agent gets in production.
Three ways Tulip meets the ecosystem (don't conflate them)
This package is for the first one. It helps to see all three, because the same names people call "integrations" are actually three different relationships:
- Gate — agent frameworks whose tools take actions (LangChain, LangGraph,
CrewAI, the OpenAI Agents SDK, LlamaIndex, Google ADK). This package's
gate_*_tool. - Compose — model-call gateways (LiteLLM, Portkey): they route the model call;
Tulip gates the action. They stack, they don't compete — see
examples/litellm_two_layer.py. - Assure — another agent you don't control (a chatbot, an endpoint, an
OpenClaw-style runtime): red-team it as a
Targetwith the core SDK'sred_team().
Examples
Runnable, per-framework, under examples/:
| File | Shows |
|---|---|
langchain_refund_gate.py |
Gate a refund tool; production held for a human |
langgraph_soc_containment.py |
Gate a graph node that performs a side effect |
crewai_account_gate.py |
Gate a CrewAI tool (sync execution model) |
adk_account_gate.py |
Gate a Google ADK FunctionTool |
litellm_two_layer.py |
Compose: LiteLLM routes the model, Tulip gates the action |
Status
v0.1 ships gate bridges for LangChain, LangGraph, the OpenAI Agents SDK, CrewAI, LlamaIndex, and Google ADK, plus the framework-agnostic core, an out-of-band approval protocol, and offline testing helpers. The LangChain/LangGraph and OpenAI Agents bridges are the most exercised, including end-to-end tests against a real LLM; CrewAI, LlamaIndex, and ADK follow the identical pattern.
One-way dependency on tulip-agents (the same direction as the
langchain-core / langchain-community split). Apache-2.0.
→ Full docs: https://tulipagents.ai/integrations/frameworks/ · Core SDK: https://github.com/tuliplabs-ai/sdk-python
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file tulip_frameworks-1.0.0.tar.gz.
File metadata
- Download URL: tulip_frameworks-1.0.0.tar.gz
- Upload date:
- Size: 56.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
935e4a761395095e7c8b991bf1bde6f968d6877ad9e8eb8146890b80821a845a
|
|
| MD5 |
bc71d2563ad687c91afd8e07b1a1792e
|
|
| BLAKE2b-256 |
3d8dea866c68d661c54e44c6045e8a4d6f482f5f63a080dc8ec50dd0df982182
|
Provenance
The following attestation bundles were made for tulip_frameworks-1.0.0.tar.gz:
Publisher:
_release.yml on tuliplabs-ai/tulip-frameworks
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
tulip_frameworks-1.0.0.tar.gz -
Subject digest:
935e4a761395095e7c8b991bf1bde6f968d6877ad9e8eb8146890b80821a845a - Sigstore transparency entry: 2494930693
- Sigstore integration time:
-
Permalink:
tuliplabs-ai/tulip-frameworks@806907dd46c839f037fda9982e15fc2a749c7add -
Branch / Tag:
refs/heads/main - Owner: https://github.com/tuliplabs-ai
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
_release.yml@806907dd46c839f037fda9982e15fc2a749c7add -
Trigger Event:
workflow_dispatch
-
Statement type:
File details
Details for the file tulip_frameworks-1.0.0-py3-none-any.whl.
File metadata
- Download URL: tulip_frameworks-1.0.0-py3-none-any.whl
- Upload date:
- Size: 35.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
69717cb64f6ae71c0b70e25b944ae8146c050fb5c740b8b4e9a8532af49f53cb
|
|
| MD5 |
2ee69fedde3e791501cce88723fd9409
|
|
| BLAKE2b-256 |
e309df9b350f89737b71730e886ead0820c2f2c065ebfac6905b34a2759e8825
|
Provenance
The following attestation bundles were made for tulip_frameworks-1.0.0-py3-none-any.whl:
Publisher:
_release.yml on tuliplabs-ai/tulip-frameworks
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
tulip_frameworks-1.0.0-py3-none-any.whl -
Subject digest:
69717cb64f6ae71c0b70e25b944ae8146c050fb5c740b8b4e9a8532af49f53cb - Sigstore transparency entry: 2494930722
- Sigstore integration time:
-
Permalink:
tuliplabs-ai/tulip-frameworks@806907dd46c839f037fda9982e15fc2a749c7add -
Branch / Tag:
refs/heads/main - Owner: https://github.com/tuliplabs-ai
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
_release.yml@806907dd46c839f037fda9982e15fc2a749c7add -
Trigger Event:
workflow_dispatch
-
Statement type: