Integration recipes¶
Each recipe uses the public Python surface and runs without a provider
credential unless it says otherwise. Run them from an environment installed
with just setup or the manual steps, which include the
langchain extra, and set AGENTIC_NO_LLM=1 so nothing can reach a provider.
If you install pieces by hand, install the root tools package first and pass the constraints file to every install:
python -m pip install -e . -c ci-constraints.txt
python -m pip install -e "./agentic-workflows-v2[langchain]" -c ci-constraints.txt
Without the langchain extra the LangChain adapter is not registered and
list_adapters() returns only ['native'].
1. Run a workflow on either execution engine¶
Both engines are reached through the AdapterRegistry. A named YAML
workflow loads and runs the same way on each of them:
import asyncio
from agentic_v2.adapters import get_registry
async def main() -> None:
registry = get_registry()
print("adapters:", registry.list_adapters())
for name in ("native", "langchain"):
engine = registry.get_adapter(name)
workflow = engine.load_workflow("test_deterministic")
result = await engine.execute(
workflow, workflow_inputs={"input_text": "Hello World"}
)
print(name, result.overall_status.value, result.final_output)
asyncio.run(main())
Both lines report success with the same processed_text and step_count.
The command-line equivalent is
agentic compare test_deterministic --input in.json --adapters native,langchain;
see the CLI reference.
A runtime-built DAG is different: only the native engine accepts it.
LangChainEngine takes a workflow name, a path, or a LangChain
WorkflowConfig and raises TypeError for a DAG, and NativeEngine raises
TypeError for a bare workflow name. Step functions are async, and what a
step returns lands under that step's outputs unless output_mapping copies it
into a context variable for later steps:
import asyncio
from agentic_v2.adapters import get_registry
from agentic_v2.engine import DAG, ExecutionContext, StepDefinition
async def ingest(ctx: ExecutionContext) -> dict[str, str]:
return {"cleaned": ctx.get_sync("raw").strip().upper()}
async def count(ctx: ExecutionContext) -> dict[str, int]:
return {"length": len(ctx.get_sync("cleaned"))}
async def main() -> None:
dag = DAG(name="adapter_demo")
dag.add(
StepDefinition(
name="ingest", func=ingest, output_mapping={"cleaned": "cleaned"}
)
).add(
StepDefinition(
name="count",
func=count,
depends_on=["ingest"],
output_mapping={"length": "length"},
)
)
ctx = ExecutionContext(variables={"raw": " agentic runtime "})
result = await get_registry().get_adapter("native").execute(dag, ctx)
print(result.overall_status.value, ctx.get_sync("cleaned"), ctx.get_sync("length"))
asyncio.run(main())
This prints success AGENTIC RUNTIME 15. See
Runtime architecture for how the registry
selects an engine.
2. Cap model cost with a cost-lane ceiling¶
AGENTIC_MAX_COST_LANE (local, free, or paid, the default) drops
candidate models above the ceiling by their curated cost_lane in
model_registry.yaml. An id missing from the registry counts as paid. The
variable is read on every call, so set it in the process environment; the
assignment below only keeps the example self-contained.
SmartModelRouter excludes over-ceiling models inside a tier and, when a tier
has none left, degrades to another tier and records that on last_selection.
It raises only when every tier is exhausted (and AGENTIC_NO_LLM is unset):
import os
from agentic_v2.models import FallbackChain, ModelTier, SmartModelRouter
os.environ["AGENTIC_MAX_COST_LANE"] = "free"
router = SmartModelRouter()
router.register_chain(
ModelTier.TIER_2,
FallbackChain.build("lane")
.add("anthropic:claude-sonnet-4-6") # paid: excluded by the ceiling
.add("ollama:qwen3-coder:30b") # local: allowed
.done(),
)
print(router.get_model_for_tier(ModelTier.TIER_2), router.last_selection)
# A tier whose whole chain is above the ceiling degrades to another tier.
router.register_chain(
ModelTier.TIER_4,
FallbackChain(("anthropic:claude-sonnet-4-6",), name="paid-only"),
)
print(router.get_model_for_tier(ModelTier.TIER_4), router.last_selection)
The first selection is TIER_2 with is_degraded=False; the second reports
requested_tier='TIER_4', actual_tier='TIER_3', and is_degraded=True.
The simpler ModelRouter, and the LangChain model builder, behave differently:
when the ceiling filters out every candidate they refuse instead of degrading,
raising CostLaneCeilingExceededError:
import os
from agentic_v2.models import FallbackChain, ModelRouter, ModelTier
from agentic_v2.models.model_registry import CostLaneCeilingExceededError
os.environ["AGENTIC_MAX_COST_LANE"] = "free"
router = ModelRouter()
router.register_chain(
ModelTier.TIER_4, FallbackChain(("anthropic:claude-sonnet-4-6",), name="paid-only")
)
try:
router.get_model_for_tier(ModelTier.TIER_4)
except CostLaneCeilingExceededError as exc:
print("refused:", exc)
Tiers run from TIER_0 (deterministic, no model) to TIER_5. Mistral and
DigitalOcean model ids are served only by the LangChain engine; the native
backend fails loudly for them. See Configuration and
ADR-059.
3. Wire tool approval¶
Every tool call that needs approval goes through
evaluate_tool_approval(). A call needs approval when the tool sets
requires_approval (the high-impact built-ins such as file_write), when
AGENTIC_REQUIRE_TOOL_APPROVAL is on (every tool), or when its name is listed
in AGENTIC_APPROVAL_REQUIRED_TOOLS. Read-only tools such as file_read are
not gated by default.
With no provider registered, a gated call is denied (fail-closed).
AutoDenyProvider is a separate, explicit kill switch and is never installed
for you:
import asyncio
from agentic_v2.governance import (
ApprovalDecision,
ApprovalRequest,
CallbackApprovalProvider,
PolicyApprovalProvider,
evaluate_tool_approval,
set_approval_provider,
)
from agentic_v2.tools.builtin import FileReadTool, FileWriteTool
async def check(tool, name: str, label: str) -> None:
outcome = await evaluate_tool_approval(
tool, name, {"path": "notes.txt"}, call_id="c1", agent_or_step="demo"
)
print(f"{label:<28} allowed={outcome.allowed} via {outcome.provider_label}")
async def main() -> None:
write, read = FileWriteTool(), FileReadTool()
await check(write, "file_write", "no provider, gated tool")
await check(read, "file_read", "no provider, read-only tool")
set_approval_provider(PolicyApprovalProvider(frozenset({"file_write"})))
await check(write, "file_write", "allowlisted tool")
def operator_prompt(request: ApprovalRequest) -> ApprovalDecision:
print(" asked to approve:", request.tool_name)
return ApprovalDecision.DENIED
set_approval_provider(CallbackApprovalProvider(operator_prompt))
await check(write, "file_write", "callback provider")
set_approval_provider(None)
asyncio.run(main())
The first line is denied via no provider registered, the read-only tool is
not-required, the allowlisted tool is approved, and the callback decides for
itself. The callback may also be async, which is the hook for an interactive
prompt or a remote approval service. Register a provider once at process
start. The structural gate itself (BaseTool.__init_subclass__) needs no
extra install; see ADR-041,
ADR-047, and
Known limitations section 4.3.
ExecutionKit delegation (ek extra)¶
Separately from the gate, the optional ek extra routes model calls through
the ExecutionKit-backed provider:
With the package installed, AGENTIC_EK_PROVIDER defaults to 1 and ordinary
get_client().complete(...) calls and LLM steps use it automatically; set
AGENTIC_EK_PROVIDER=0 to roll back to the legacy path. To observe each
physical attempt, build the provider yourself and pass an attempt_callback
(each ProviderAttempt records model, latency_ms, ok, error_type, and
streaming):
import asyncio
from agentic_v2.models import MockBackend, ModelTier, SmartModelRouter
from agentic_v2.models.ek_provider import ProviderAttempt, SmartRouterProvider
async def main() -> None:
attempts: list[ProviderAttempt] = []
provider = SmartRouterProvider(
SmartModelRouter(),
MockBackend(default_response="hello"),
ModelTier.TIER_2,
attempt_callback=attempts.append,
)
response = await provider.complete([{"role": "user", "content": "Say hi"}])
print(response.content, attempts)
asyncio.run(main())
See ADR-023.
4. Use Claude through a subscription sign-in (claude extra)¶
This recipe needs the claude extra and a signed-in Claude Code, so it does
not run in a key-free environment:
ClaudeSDKAgent wraps the Claude Agent SDK. It is not a BaseAgent and
bypasses the tool registry; the SDK's own tools (Read, Glob, Grep,
Bash, ...) do the work:
import asyncio
from agentic_v2.agents.implementations import ClaudeSDKAgent
async def main() -> None:
agent = ClaudeSDKAgent(tools=["Read", "Glob", "Grep"], cwd=".")
print(await agent.run("List the top-level directories in one line each."))
asyncio.run(main())
To use the same sign-in as a model backend, use an id with the claude:
prefix. The backend registers itself when the SDK is importable. The child
process has ANTHROPIC_API_KEY blanked so the subscription, not an API key, is
billed (ADR-058). See
Agents deep dive.
5. Score output against a rubric¶
CriterionScorer is the key-free weighted scorer and needs no extra. Weights
live in the rubric:
from agentic_v2.scoring.criterion_aggregation import CriterionScorer
rubric = {
"criteria": [
{"name": "correctness", "weight": 0.5},
{"name": "completeness", "weight": 0.3},
{"name": "safety", "weight": 0.2},
]
}
scorer = CriterionScorer(rubric)
full = scorer.score({"correctness": 0.92, "completeness": 0.85, "safety": 1.0})
print(round(full.weighted_score, 4), full.missing_criteria)
partial = scorer.score({"correctness": 0.92, "completeness": 0.85})
print(round(partial.weighted_score, 4), partial.missing_criteria)
This prints 0.915 () and then 0.715 ('safety',): a missing criterion keeps
its weight in the denominator, so it lowers the score and is listed in
missing_criteria.
With the eval extra (agentic-evalkit, see
ADR-042), the evalkit bridge
scores the same input and returns 0.915:
from agentic_v2.scoring import evalkit_bridge
print(evalkit_bridge.EVALKIT_AVAILABLE)
print(
evalkit_bridge.score_criteria(
rubric, {"correctness": 0.92, "completeness": 0.85, "safety": 1.0}
)
)
The 0.80 figure is not a general pass mark. It is the --threshold the
golden-dataset gate in CI uses, and you can run that gate locally from the
repository root:
AGENTIC_NO_LLM=1 python scripts/eval_gate.py \
--cases datasets/default/golden_cases.json --threshold 0.80
The runtime scorer's own default pass threshold is 70.0 on a 0-100 scale;
see Gating.