A useful answer is the beginning, not the finish line

Most agent demos end when the model produces a plausible response. Production starts when that response may cause something to happen.

Consider a support agent asked to resolve a customer’s access problem and send the answer. It needs to read an account, inspect open tickets, reason from current facts, draft a reply, wait for review, and send exactly once. An operator should later be able to answer which model route ran, which records were read, what write was proposed, who approved it, and whether a fallback changed the execution path.

No single framework boundary provides all of that.

LangChain is the agent harness. It owns the model-and-tool loop, tool schemas, middleware, checkpointed conversation state, and human decision that resumes a paused action. Punk is the governed runtime beneath the harness. It receives model traffic, evaluates policy, records route and cost evidence, traces declared local tools, and can reuse eligible read results within scoped safety boundaries.

The division is deliberate:

  • LangChain decides how the agent proceeds.
  • The application owns local tool execution and business semantics.
  • Punk governs and explains the model path and declared tool activity.
  • The external system remains responsible for enforcing a write’s idempotency key.

Treating those as four independent responsibilities is less convenient than calling the whole system “governed.” It is also much harder to fool yourself.

The complete, executable source lives in the public governed LangChain support agent example. This guide explains the architecture and the operating decisions behind it. The repository is the canonical source for exact dependencies, tests, and implementation details.

The governing principle: controls must correlate across execution records

The model call happens at the Punk gateway. The CRM and support tools execute inside the application process. Human approval pauses LangGraph state. The final write occurs in another system.

If those boundaries produce unrelated logs, the result is observability theater. An operator can see several things happened near the same time but cannot prove they belonged to the same decision.

The integration therefore carries the Punk run identifier from the gateway response into the local invocation context. Each traced tool call supplies that identifier back to Punk. The model route, read tools, an approved write once it begins execution, completion state, and later explanation can then be inspected as one Punk run. The approval interrupt and reviewer decision remain LangGraph records; production systems must persist their association with the Punk run and business request.

Support request
    |
    v
LangChain agent and LangGraph state
    |
    v
ChatOpenAI -> Punk gateway -> qualified model route
    |                         |
    |                         +-> run id and route evidence
    v
Local read tools -> Punk tool trace and scoped cache
    |
    v
LangChain approval interrupt and decision record
    |
    v
Approved write -> Punk side-effect evidence -> support system

The run identifier is correlation, not authority. Possessing it does not grant permission to execute a tool. It simply prevents the model step and the local work it proposed from becoming two unrelated operational stories.

This also keeps incident review honest. A run can show that a model proposed a sound action while the local client failed, or that a tool completed while the model later summarized it incorrectly. Those are different failures with different owners. Joining the evidence should make those boundaries easier to see, not flatten them into one green or red status.

Route ChatOpenAI through Punk without replacing the harness

Punk exposes an OpenAI-compatible gateway, so the LangChain application can continue using ChatOpenAI. The Punk SDK helper supplies the gateway base URL, tenant credential, and identity headers.

import { ChatOpenAI } from "@langchain/openai";
import { Punk } from "@punktechnologies/sdk";
import {
  createPunkLangChainConfig,
} from "@punktechnologies/sdk/langchain";

const identity = {
  baseUrl: process.env.PUNK_BASE_URL,
  apiKey: process.env.PUNK_API_KEY,
  app: "langchain-support",
  agent: "resolution-agent",
  subject: "customer-42",
};

export const punk = new Punk(identity);

export const model = new ChatOpenAI({
  model: process.env.PUNK_MODEL ?? "gpt-4o-mini",
  temperature: 0,
  ...createPunkLangChainConfig(identity),
});

Those identity dimensions are part of the execution boundary. app groups the product workload. agent distinguishes the acting component. subject scopes the end-user or account context. Use a stable pseudonymous subject, not an email address or another unnecessary piece of personal data.

Start with an observe-mode tenant key. The live provider remains authoritative while Punk records the route, policy, cache opportunity, and optimization evidence. Do not switch a whole agent estate into optimization because one demo looks correct. Review a narrow workload, establish the acceptable fallback and freshness behavior, and promote only paths that have earned evidence.

That sequence expresses an important Punk opinion: optimization is a serving privilege, not a property of generated code.

Bridge the gateway run into local tools

Gateway visibility does not automatically include local tools. A LangChain tool may read a database or call an email provider without crossing the model gateway at all.

The example uses a custom fetch wrapper to capture the Punk run response header and stores it in invocation-local context. When a tool runs, the wrapper passes the current run ID to traceTool.

AsyncLocalStorage is appropriate for a compact, single-process TypeScript example because it keeps concurrent invocations separate. It is not a distributed workflow store. If approval resumes in another process, persist the LangGraph thread ID, business request ID, idempotency key, and Punk run association explicitly.

The same caution applies to response-header capture. Framework adapters can change how raw provider metadata is surfaced. Keep the bridge small, test it against the supported LangChain versions, and fail visibly when no run ID is captured. A tool may still need to execute if telemetry is temporarily unavailable, but an operator should not mistake an uncorrelated execution for complete evidence.

Declare operational meaning before writing tool code

Tool names and JSON schemas tell the model how to call a function. They do not tell the runtime what the function can do to the world.

Punk classifies tools on a five-level side-effect scale:

  • Level 0 — pure computation. Parse or format data; cacheable with a TTL.
  • Level 1 — read-only external call. CRM lookup or search; cacheable with a TTL.
  • Level 2 — reversible or idempotent write. Upsert with a stable key; never cached.
  • Level 3 — user-visible write. Email or ticket reply; never cached.
  • Level 4 — high-impact write. Payment, deletion, or permission change; never cached.

An undeclared tool defaults to level 3. That conservative default prevents an unknown tool from being treated as harmless, but it is not documentation. Production tools should be declared explicitly.

The support example has two reads and one user-visible write:

const accountLookup = punk.traceTool({
  name: "crm.lookupAccount",
  sideEffectLevel: 1,
  ttlSeconds: 300,
  schemaFp: "crm.lookupAccount.v1",
  execute: ({ accountId }) => crm.lookupAccount(accountId),
});

const openTickets = punk.traceTool({
  name: "support.listOpenTickets",
  sideEffectLevel: 1,
  ttlSeconds: 60,
  schemaFp: "support.listOpenTickets.v1",
  execute: ({ accountId }) => support.listOpenTickets(accountId),
});

const sendReply = punk.traceTool({
  name: "support.sendReply",
  sideEffectLevel: 3,
  schemaFp: "support.sendReply.v1",
  idempotencyKey: ({ requestId }) => requestId,
  execute: (args) => support.sendReply(args),
});

The two TTLs are intentionally different. Account entitlement data and an open-ticket list do not necessarily share a freshness budget. A five-minute cache is not a statement that all CRM data is fresh for five minutes; it is a workload decision for this particular read contract.

The schema fingerprint matters for the same reason. A cached result produced under version one of a tool contract should not silently satisfy an incompatible version two.

Punk’s tool-result cache is restricted to declared level 0 and level 1 tools with a positive TTL. Cache authority is server-owned and incorporates tenant, application, subject, grants, connector state, credentials, and policy context. If the runtime cannot establish an unambiguous eligible scope, the read executes normally.

That is the right failure direction. A cache miss costs time. Cross-authority reuse costs trust.

Approval, policy, and idempotency are different controls

The most common design error in side-effecting agents is to pick one control and pretend it covers the other failure modes.

Human approval answers: should this proposed action execute now?

Policy answers: is this action permitted under the current tenant, identity, and operational rules?

Idempotency answers: if the approved request is retried, will the external system apply it more than once?

Replay and shadow suppression answer: can the system evaluate a historical or candidate path without repeating the side effect?

None substitutes for another. An approved email can still be duplicated by a retry. An idempotent write can still violate policy. A policy-allowed action may still require a person to edit the recipient. A suppressed replay does not prove that the live provider honors idempotency.

For levels 2 through 4, Punk records a planned side effect before execution. When an idempotency resolver is configured, Punk includes the stable non-secret key in that evidence. Punk does not inject the key into the external request. The application must send the same key, and the external service must enforce it.

This boundary should be visible in code review. If idempotencyKey appears only in the trace declaration but not in the client call, the system records an intention it does not enforce.

Pause only the consequential tool

LangChain’s human-in-the-loop middleware can interrupt selected tool calls, persist graph state through a checkpointer, and resume with an approve, edit, or reject decision. Reads should proceed without a review ceremony; the customer-visible reply should pause.

import { MemorySaver } from "@langchain/langgraph";
import {
  createAgent,
  humanInTheLoopMiddleware,
} from "langchain";

export const agent = createAgent({
  model,
  tools: [lookupAccountTool, listOpenTicketsTool, sendReplyTool],
  systemPrompt,
  middleware: [
    humanInTheLoopMiddleware({
      interruptOn: {
        crm_lookup_account: false,
        support_list_open_tickets: false,
        support_send_reply: {
          allowedDecisions: ["approve", "edit", "reject"],
          description: "Customer reply requires approval",
        },
      },
    }),
  ],
  checkpointer: new MemorySaver(),
});

The approval UI must present the actual proposed arguments. “Approve agent response” is too vague. A reviewer needs the tool name, ticket, recipient, subject, and complete reply body. If several actions are interrupted together, decisions must remain aligned with the actions in the order LangChain returns them.

Use approve when the exact proposal should run. Use edit for a conservative correction such as changing the reply body. Use reject with explicit feedback when the action must not execute. Do not convert a denied write into a synthetic success; the model must know the tool did not run.

MemorySaver keeps the example easy to run. A production queue needs a durable checkpointer such as a supported database-backed LangGraph saver. It also needs authorization around the reviewer, expiry behavior for abandoned approvals, and a durable business request ID. Restarting a process must not erase the only record that a write was waiting.

Make the request identity survive retries

A random ID created at process startup is useful for a new request and useless for recognizing a retry of an old one.

Generate the business request ID at the durable boundary where the support request enters the system. Persist it with the ticket and approval record. Derive the write key deterministically, for example:

support-reply:{request-id}:{ticket-id}

Use that same value for Punk’s idempotencyKey evidence and the support provider’s idempotency mechanism. Bound its length, exclude secrets, and never derive it from mutable prose. Editing the reply should not accidentally turn the same approved business operation into an unrelated duplicate unless that is an explicit product decision.

The local example includes deterministic fixtures to make the control flow inspectable. Its in-memory store demonstrates behavior inside the example process; it is not evidence about a real provider’s retry guarantees. Before production, test duplicate delivery against the actual support API, including timeouts where the client does not know whether the first write completed.

That ambiguous-timeout case is the test that matters. Happy-path “send once” behavior says nothing about idempotency.

Inspect evidence as part of completion

An agent should not declare a consequential action complete merely because the final model message says it happened. The tool result must confirm the write, and the operating surface should expose the evidence needed to investigate it.

For each captured Punk run, inspect the route explanation and side-effect records:

const explanation = await punk.explain(runId);
const sideEffects = await punk.sideEffectsForRun(runId);

console.dir({ runId, explanation, sideEffects }, { depth: 8 });

The useful questions for the Punk run are operational:

  • Did the request use the expected tenant, app, agent, and subject?
  • Which model or qualified optimized route served it?
  • Did each local tool attach to the intended run?
  • Were repeated reads served under an eligible current scope?
  • Was the write recorded as planned before execution?
  • Did the recorded idempotency key match the provider request?
  • If fallback occurred, is the reason explicit?

Inspect the separate LangGraph approval record for whether the reply was approved, edited, or rejected, who made that decision, and which business request and Punk run it belongs to. The local example demonstrates the decision flow but does not persist reviewer identity.

A route explanation is not a certificate that the answer was correct. It is a structured account of how the response was served and why alternatives were accepted or rejected. Pair that evidence with application outcomes, tests, and the separately persisted human-review record.

Validate boundaries, not just output text

The repository example is designed to make the boundaries testable without presenting fixture behavior as a customer benchmark. A serious integration test plan should cover at least:

  1. Account and ticket reads execute under the expected subject and attach to the model run.
  2. An eligible repeated read can be reused, while a different subject or changed contract cannot receive that result.
  3. The send tool interrupts before its implementation executes.
  4. Approval executes the original arguments once.
  5. Edit executes only the reviewed replacement.
  6. Rejection does not execute the write.
  7. A stable request ID reaches both Punk evidence and the external provider.
  8. A duplicate provider request with that key does not create a second reply.
  9. Missing trace connectivity does not silently become “complete evidence.”
  10. Replay and shadow evaluation suppress side effects.

Treat that list as release requirements, not as proof supplied by a fixture. Local tests can verify narrow application contracts such as interrupt handling and stable key propagation. Cache isolation, provider duplicate protection, and runtime replay or shadow behavior need tests at the boundaries that actually enforce them. Keep each verified result separate in release notes and dashboards.

The best agent test is not “did it say the right thing?” It is “did every boundary behave correctly when the answer became an action?”

Production hardening checklist

Before sending real customer replies:

  • Pin and lock the tested LangChain, LangGraph, OpenAI adapter, Punk SDK, TypeScript, and runtime versions.
  • Replace in-memory fixtures with authenticated clients and explicit timeouts.
  • Replace MemorySaver with a durable LangGraph checkpointer.
  • Persist the thread ID, business request ID, Punk run association, approval state, and provider idempotency key.
  • Define TTLs from actual freshness requirements, not convenience.
  • Version tool schemas when arguments or result meaning changes.
  • Use pseudonymous subjects and keep secrets out of trace arguments and keys.
  • Authorize reviewers and record decision identity outside the model conversation.
  • Exercise approve, edit, reject, retry, timeout, fallback, and policy-denial paths.
  • Start in observe mode and review real evidence before enabling optimization for a bounded workload.

Most importantly, decide who owns each failure. The harness owns workflow recovery. The application owns correct local execution. The provider owns its documented idempotency behavior. Punk owns governed routing, trace evidence, cache eligibility, and safe optimization boundaries.

Production agents become manageable when those responsibilities are explicit. They become dangerous when a polished final answer is allowed to blur them together.

Questions readers ask

Does Punk replace LangChain or LangGraph?

No. LangChain remains the agent harness and LangGraph owns its workflow state and pause-and-resume behavior. Punk governs model traffic, traces declared local tools, evaluates policy, records side effects, and explains how each model step was served.

Does recording an idempotency key make a write idempotent?

No. Punk records the key as structured evidence, but the tool implementation must send that same key to the external service, and that service must enforce duplicate protection. Approval and idempotency solve different failure modes.

Can Punk cache customer-visible write tools?

No. Only declared level 0 and level 1 tools with a positive TTL are eligible for tool-result caching. Writes at levels 2 through 4 are not cached; Punk records a planned side effect and applies the relevant policy and replay or shadow safeguards.

Is MemorySaver appropriate for a production approval queue?

No. It is useful for a local example, but production pause-and-resume workflows need a durable LangGraph checkpointer and a persisted association between the LangGraph thread, the business request, and the Punk run evidence.

Sources

  1. Complete governed LangChain support agent example
  2. Punk LangChain integration
  3. Punk SDK reference
  4. Punk agent observability and tool caching
  5. LangChain agents
  6. LangChain human-in-the-loop middleware