The Lumbox Python SDK has a one-line LangChain integration, create_langchain_tools(), covered in the post on the prebuilt tools. It is receive-only, it returns whole JSON documents, and it is Python-only. This post is for the cases where that set does not fit: your agent has to send email, you want tool output the model can read in one glance, you want a human to approve outgoing mail, or your stack is LangChain.js.

Writing the tools yourself takes about 45 lines. They call the REST API directly, so every decision about naming, errors and output is yours.

Three tools against the REST API

This is the whole module. It uses httpx with one shared client and the @tool decorator from langchain.tools, which builds the argument schema from type hints and the description from the docstring.

import hashlib
import json
import os

import httpx
from langchain.tools import tool

lumbox = httpx.Client(
    base_url="https://api.lumbox.co/v1",
    headers={"X-API-Key": os.environ["LUMBOX_API_KEY"]},
    timeout=httpx.Timeout(10.0, read=70.0),
)


@tool
def create_inbox() -> str:
    """Create a fresh email inbox for one signup. Returns 'inbox_id address'."""
    r = lumbox.post("/inboxes", json={})
    r.raise_for_status()
    d = r.json()
    return f"{d['id']} {d['address']}"


@tool
def get_code(inbox_id: str, sender: str) -> str:
    """Wait up to 60 seconds for a verification code from sender (a domain such as
    'github.com'). Returns the code, or NO_CODE if none arrived."""
    r = lumbox.get(f"/inboxes/{inbox_id}/otp", params={"from": sender, "timeout": 60})
    if r.status_code == 408:
        return "NO_CODE"
    r.raise_for_status()
    return r.json()["code"]


@tool
def send_email(inbox_id: str, to: str, subject: str, text: str) -> str:
    """Send a plain-text email from an inbox. Returns the email id."""
    payload = {"to": to, "subject": subject, "text": text}
    key = hashlib.sha256(json.dumps([inbox_id, payload], sort_keys=True).encode()).hexdigest()
    r = lumbox.post(f"/inboxes/{inbox_id}/send", json=payload, headers={"Idempotency-Key": key})
    if r.status_code == 429:
        return f"RATE_LIMITED retry_after={r.json().get('retry_after_seconds')}"
    r.raise_for_status()
    return r.json()["email_id"]

Each choice in that module handles a specific failure.

The read timeout is longer than the long poll

GET /v1/inboxes/:id/otp holds the request open and checks for a matching email once a second, up to the timeout you pass (the server clamps it to 1 to 120 seconds). The client's read timeout has to outlast that, or httpx gives up at 5 seconds, its default, while the server is still waiting for you. Here the tool asks for 60 and the client allows 70.

create_inbox sends no name

The inbox address comes from local_part or name if you send one, lowercased with anything outside a-z, 0-9 and - replaced. A tool that always passes name="signup-bot" works once. The second call returns 409 because signup-bot@trylumbox.com already exists. With an empty body the API generates a unique agent_... local part on the default trylumbox.com domain.

A timeout returns NO_CODE

No code within 60 seconds is a normal outcome: the service is slow, the email went to the wrong address, or the form never submitted. get_code returns NO_CODE so the model can decide to press "resend" or check the form. Anything else, such as a 401 or a 404 for a wrong inbox id, raises. LangChain's default error handling re-raises tool exceptions other than bad arguments, so those end the run, which is what you want for a bad key.

The output is one string. The prebuilt tools return the full email object, including the fenced text body and sanitised HTML. That is useful for reading an email and wasteful when the model needs six digits.

Sends are idempotent by content

Models retry. If a send times out on the client side, or the model is unsure whether the first call worked, it calls send_email again with the same arguments. The key here is a hash of the inbox and payload, sent as Idempotency-Key. Lumbox stores the response for 24 hours; a repeat with the same key and body returns the stored response with an Idempotent-Replayed: true header instead of sending twice. The same key with a different body gets a 409, which cannot happen here because the key is derived from the body.

The 429 branch matters on the free plan. Sends are limited per minute (3 on Free, 30 on Starter), and a new free account without a verified domain can send 5 emails in its first hour. The response body carries retry_after_seconds, which the tool passes to the model as text.

Put a human in front of send_email

Reading a code affects nobody else. Sending reaches a real person, so gate it. LangChain's HumanInTheLoopMiddleware pauses the run before a named tool executes. It needs a checkpointer and a thread id so the paused run can be resumed:

from langchain.agents import create_agent
from langchain.agents.middleware import HumanInTheLoopMiddleware
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.types import Command

agent = create_agent(
    model="openai:gpt-5.5",
    tools=[create_inbox, get_code, send_email],
    middleware=[HumanInTheLoopMiddleware(interrupt_on={"send_email": True})],
    checkpointer=InMemorySaver(),
)

config = {"configurable": {"thread_id": "signup-42"}}
result = agent.invoke({"messages": [{"role": "user", "content": "Email the owner a summary"}]}, config=config)
print(result["__interrupt__"])  # the pending send_email call and its arguments

result = agent.invoke(Command(resume={"decisions": [{"type": "approve"}]}), config=config)

Against a local mock of the API, the first invoke stopped with the pending call and zero requests reached the send endpoint. The API call happened only after the approve decision. If you would rather preview inside the tool, POST /v1/inboxes/:id/send accepts "dry_run": true and returns the from address, recipients, subject and a 500 character body preview without sending.

Using the tools without an agent

"Any chain" is meant literally. A LangChain tool is a runnable, so a fixed pipeline can call it with .invoke and no model deciding anything:

inbox_id, address = create_inbox.invoke({}).split()
submit_signup_form(address)
code = get_code.invoke({"inbox_id": inbox_id, "sender": "example.com"})

A signup where you already know the steps does not need a model to choose them. The same tool objects can go into an agent later without changes.

The same tool in LangChain.js

In LangChain.js, tool() from the langchain package takes the function and a zod schema:

import { tool } from "langchain";
import * as z from "zod";

const API = "https://api.lumbox.co/v1";
const headers = { "X-API-Key": process.env.LUMBOX_API_KEY! };

export const getCode = tool(
  async ({ inboxId, sender }) => {
    const url = `${API}/inboxes/${inboxId}/otp?` + new URLSearchParams({ from: sender, timeout: "60" });
    const res = await fetch(url, { headers });
    if (res.status === 408) return "NO_CODE";
    if (!res.ok) throw new Error(`Lumbox ${res.status}: ${await res.text()}`);
    return (await res.json()).code;
  },
  {
    name: "get_code",
    description: "Wait up to 60 seconds for a verification code from sender (a domain such as 'github.com'). Returns the code or NO_CODE.",
    schema: z.object({ inboxId: z.string(), sender: z.string() }),
  },
);

Pass it to createAgent({ model, tools: [getCode] }) the same way. A JS sendEmail tool is the same shape: a POST to /v1/inboxes/:id/send with the JSON body and Idempotency-Key header from the Python version, returning email_id from the response.

What these three leave out

They do not read bodies or links, so a service that verifies by link needs a fourth tool over GET /v1/inboxes/:id/emails, whose items carry parsed.verification_links. They do not reply in a thread; POST /v1/inboxes/:id/reply takes email_id and text and sets In-Reply-To and References for you. They also never delete an inbox, and inboxes do not expire, so the free plan's 3 inboxes fill up unless your code calls DELETE /v1/inboxes/:id when a run finishes.

The request and response fields for send, reply and forward are in the sending docs.