STACK
MENU
DOCS / INTEGRATIONS / LANGCHAIN

LangChain integration

Wrap STACK proxy calls as LangChain tools. The provider credential stays in STACK. The tool call carries Passport scope and produces an audit entry.

1. Install + sign in

bash
pip install langchain langchain-openai getstack
npx -y @getstackrun/cli auth login

STACK's Python SDK ships on PyPI as getstack. The CLI's auth login opens a browser, runs the OAuth Device flow, and stores a refresh token at ~/.stack/credentials.json. The Python SDK reads it automatically -- no STACK_API_KEY env var needed for local dev. (CI without a browser keeps the env-var path -- see step 7.)

2. Connect a service

Before wiring code, connect at least one service to your operator at getstack.run/services. Complete provider OAuth in Connect. STACK stores the resulting token KMS-encrypted; the agent does not receive it.

3. Register the agent (one-time)

python
from getstack import Stack

stack = Stack()  # reads ~/.stack/credentials.json automatically

agent = stack.agents.register(
    name="my-langchain-agent",
    description="Customer-support triage bot",
    accountability_mode="logged",  # full audit + detectors, no per-call gate
    key_mode="customer_managed",    # runtime authenticates as this agent
)
print(f"Save this: {agent.id}")  # e.g. agt_abc123

# Authorize the agent for the service you connected in step 2.
# The grant is the authorization -- a mission can only narrow it,
# never substitute for it.
conn = next(c for c in stack.services.list() if c["provider"] == "slack")
stack.services.grant_agent_access(
    agent_id=agent.id,
    service_connection_id=conn["id"],
    scopes=conn["scopes"],  # or a narrower subset
)

Register once and keep agent.id; you wire it into the runtime in step 4. Customer-managed mode is required for Stack(agent_id=...), because that client enrolls and uses a local agent keypair. Without the grant, issuing a passport that declares slack fails with a 403. accountability_mode is an agent property (not the passport): standard (audit only), logged (checkpoints + detectors, critical flags surface at review), enforced (strictest: every proxied call additionally requires a pre-approved Intent -- see 5b).

4. Open the runtime with a per-agent keypair

python
# In your agent's runtime code (the script LangChain calls into):
from getstack import Stack

# agent_id binds this Stack instance to the agent's local Ed25519
# keypair. First run generates the keypair + enrolls the public half;
# subsequent runs sign every API call with a fresh 60-second JWT.
# Your operator key never touches this process.
stack = Stack(agent_id="agt_abc123")

with stack.passports.mission(
    agent_id="agt_abc123",
    intent="Triage and acknowledge new support tickets",
    services=["slack"],
    checkpoint_interval="5m",
) as mission:
    # mission.token is the passport JWT for proxied calls
    # mission.log(...) records each tool action
    # checkpoints fire automatically every 5m
    # checkout fires automatically when the block exits
    ...

The context manager owns the passport lifecycle so your code never manages JTIs by hand. If the block raises, checkout still fires with the failure reason as the summary.

5. Route LangChain tool calls through the STACK proxy

LangChain tools that hit external APIs should call the STACK proxy instead of the upstream directly. The proxy injects the credential server-side; your agent process never sees the token.

python
from langchain_core.tools import tool

@tool
def post_to_slack(channel: str, text: str) -> dict:
    """Post a message to a Slack channel via STACK's credential proxy."""
    response = mission.proxy(
        service="slack",
        url="https://slack.com/api/chat.postMessage",
        method="POST",
        body={"channel": channel, "text": text},
    )
    return response.body

mission.proxy() auto-attaches the passport JWT, logs the tool call into the mission's checkpoint buffer, and returns a ProxyResponse with .status, .headers, and .body. Pass full URLs — the proxy validates them and rejects relative paths.

For a durable non-GET job, pass on_operation_id to persist the generated operation ID before network I/O. Reuse it only for the unchanged request and call stack.proxy.get_operation() after response loss. See Proxy operation recovery.

5b. Pre-execution approval gate

Register the agent with accountability_mode="enforced" and this gate becomes mandatory: the proxy refuses any call that does not carry an approval id. Submit the Intent, wait for the operator's approval, then thread the returned id on the producer call. Rejection auto-revokes the passport.

python
import time

result = stack.intents.submit_and_wait(
    intent={
        "type": "intent_claim",
        "intent_type": "http_call",
        "agent_id": "agt_abc123",
        "named_intent": "stripe.create_refund",
        "target": "stripe",
        "action": "POST /v1/refunds",
        "parameters": {
            "url": "https://api.stripe.com/v1/refunds",
            "method": "POST",
            "body": {"charge": "ch_abc", "amount": 4200},
        },
        "estimated_cost": {"wallet_cents": 0, "tokens": None, "gas_gwei": None},
        "accountability": "enforced",
        "reason": "Customer requested refund on ticket #4821",
        "requires": [],
        "user_subject": None,
        "mission_ref": None,
        "submitted_at": int(time.time() * 1000),
    },
    passport_token=mission.token,
    timeout_seconds=300,
)
if result["final"]["status"] != "approved":
    raise RuntimeError(f"Refund blocked: {result['final']['status']}")

mission.proxy(
    service="stripe",
    url="https://api.stripe.com/v1/refunds",
    method="POST",
    body={"charge": "ch_abc", "amount": 4200},
    approval_id=result["final"]["id"],
)

The gate checks the call shape against the approved Intent (service, method, URL host, body). Mismatch returns 403 with metadata.gate_reason="call_mismatch". stack.intents.simulate(...) is the pre-check that runs without a human round-trip.

6. Wire the tool into a LangChain agent

python
from langchain.agents import create_agent

langchain_agent = create_agent(
    model="openai:gpt-4o-mini",
    tools=[post_to_slack],
    system_prompt="You are a customer-support triage agent. Use the approved tools.",
)

langchain_agent.invoke({
    "messages": [{
        "role": "user",
        "content": "Acknowledge ticket #4821 in #support.",
    }]
})

Place this langchain_agent.invoke(...) call inside the with stack.passports.mission(...) block from step 4 — that's how the tool sees mission in scope.

7. Revocation and CI fallback

If the agent goes off-script, revoke its passport. Propagation is enforced on the next STACK-verified call.

python
stack.passports.revoke(mission.passport.jti, reason="off-script")

For a first headless deployment, set STACK_API_KEY in the deployment's secret store. It is an operator-wide credential and is used only to authorize the one-time enrollment; after that, Stack(agent_id=...) signs requests with the agent's local keypair. Remove the operator key from the runtime once enrollment has completed.

Full SDK reference: /docs/sdk/python. Underlying proxy contract: /docs/api/proxy. Tracking the run: /docs/concepts/audit. Auth model: /docs/security/stack-auth + /docs/security/agent-keys.

Enforced behavior

  • A proxy-only LangChain process does not receive raw service credentials
  • Every STACK-proxied tool call writes an audit entry with its Passport, scope, and outcome
  • Revoke with stack.passports.revoke(jti); the next STACK-verified call rejects the bot
  • Detector grid catches scope drift, credential bursts, post-checkout access in real time
stack | Docs