Documentation Index
Fetch the complete documentation index at: https://docs.placet.io/llms.txt
Use this file to discover all available pages before exploring further.
Runnable example in
examples/langchain/, a
GPT-powered chat agent with approvals, selections, and forms via WebSocket.
Overview
Placet integrates with LangChain as a human-in-the-loop tool.
Your LangChain agent receives messages in real-time via WebSocket and can send replies, request
approvals, present selections, or ask the user to fill in forms, all rendered natively in the
Placet UI.
The agent uses four tools:
| Tool | Description | Blocking |
|---|
send_message | Send a one-way message (status update, reply) | No |
ask_approval | Approve / Reject buttons | Yes |
ask_selection | Pick one option from a list | Yes |
ask_form | Fill in a multi-field form | Yes |
Blocking tools wait for the user’s response via a WebSocket review:responded event (up to 5
minutes).
Message Helper
All tools post messages through the same helper:
import requests
PLACET_URL = "https://your-placet-instance.com"
API_KEY = "hp_your-api-key"
CHANNEL_ID = "your-agent-id"
headers = {
"x-api-key": API_KEY,
"Content-Type": "application/json",
}
def post_message(**kwargs):
resp = requests.post(
f"{PLACET_URL}/api/v1/messages",
headers=headers,
json={"channelId": CHANNEL_ID, **kwargs},
)
resp.raise_for_status()
return resp.json()
send_message
Fire-and-forget: sends a message and returns immediately:
from langchain_core.tools import tool
@tool
def send_message(text: str, status: str = "info") -> str:
"""Send a plain message to the user. No response expected.
Args:
text: Markdown-formatted message text.
status: One of 'info', 'success', 'warning', 'error'.
"""
post_message(text=text, status=status)
return "Message sent."
ask_approval
Shows Approve / Reject buttons. Blocks until the user clicks one:
@tool
def ask_approval(question: str) -> str:
"""Present the user with an Approve / Reject decision.
Blocks until the user clicks a button (up to 5 minutes).
Returns: 'approve', 'reject', or 'timeout'.
"""
msg = post_message(
text=question,
status="warning",
review={
"type": "approval",
"payload": {
"options": [
{"id": "approve", "label": "Approve", "style": "primary"},
{"id": "reject", "label": "Reject", "style": "danger"},
],
},
},
)
data = wait_review(msg["id"])
if data:
return data["review"]["response"]["selectedOption"]
return "timeout"
ask_selection
Presents a list of options for the user to pick from:
@tool
def ask_selection(question: str, options: str) -> str:
"""Present multiple options for the user to choose from.
Args:
question: The question to display above the options.
options: Comma-separated list, e.g. "Option A, Option B, Option C"
Returns: The id of the selected option, or 'timeout'.
"""
items = [
{"id": o.strip().lower().replace(" ", "_"), "label": o.strip()}
for o in options.split(",")
]
msg = post_message(
text=question,
status="info",
review={"type": "selection", "payload": {"mode": "single", "items": items}},
)
data = wait_review(msg["id"])
if data:
ids = data["review"]["response"].get("selectedIds", [])
return ids[0] if ids else "unknown"
return "timeout"
Shows a form with input fields:
@tool
def ask_form(question: str, fields: str) -> str:
"""Ask the user to fill in a form with one or more fields.
Args:
question: Instruction text displayed above the form.
fields: Comma-separated field names, e.g. "Name, Email, Environment"
Returns: The filled values as "field: value" pairs, or 'timeout'.
"""
field_list = [
{"name": f.strip().lower().replace(" ", "_"), "type": "text",
"label": f.strip(), "required": True}
for f in fields.split(",")
]
msg = post_message(
text=question,
status="info",
review={"type": "form", "payload": {"fields": field_list, "submitLabel": "Submit"}},
)
data = wait_review(msg["id"])
if data:
resp = data["review"]["response"]
return ", ".join(f"{k}: {v}" for k, v in resp.items() if k != "selectedOption")
return "timeout"
Waiting for Reviews via WebSocket
The blocking tools above call wait_review(), which uses a threading.Event that gets set when
the WebSocket delivers the review:responded event for the corresponding message:
import threading
_review_events = {} # message_id → threading.Event
_review_results = {} # message_id → response data
def wait_review(message_id, timeout=300):
"""Block until the WebSocket delivers review:responded for this message."""
event = threading.Event()
_review_events[message_id] = event
event.wait(timeout=timeout)
_review_events.pop(message_id, None)
return _review_results.pop(message_id, None)
The WebSocket handler fills _review_results and signals the event:
@sio.on("review:responded", namespace="/ws")
def on_review(data):
msg_id = data.get("id")
if msg_id and msg_id in _review_events:
_review_results[msg_id] = data
_review_events[msg_id].set()
This avoids HTTP long-polling entirely. Reviews resolve in real-time over the same
WebSocket connection.
Agent
Wire the tools into a ChatGPT agent with explicit instructions to always use tools
(the agent’s plain-text output is not visible to the user):
from langchain_openai import ChatOpenAI
from langchain.agents import AgentExecutor, create_tool_calling_agent
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
llm = ChatOpenAI(model="gpt-5.2-instant")
prompt = ChatPromptTemplate.from_messages([
("system",
"You are a helpful AI assistant communicating through Placet.\n\n"
"IMPORTANT: You MUST use the provided tools to communicate. "
"NEVER reply with plain text — the user cannot see it.\n\n"
"Guidelines:\n"
"- Follow-up questions with fixed choices → use ask_selection.\n"
"- Need free-text input → use ask_form.\n"
"- Yes/no or go/no-go decision → use ask_approval.\n"
"- Everything else (replies, explanations) → use send_message.\n"
"- If any tool returns 'timeout', do NOT retry. "
"Send a message that you're stopping because the user didn't respond in time."),
MessagesPlaceholder("chat_history"),
("human", "{input}"),
MessagesPlaceholder("agent_scratchpad"),
])
tools = [send_message, ask_approval, ask_selection, ask_form]
agent = create_tool_calling_agent(llm, tools, prompt)
executor = AgentExecutor(agent=agent, tools=tools, verbose=True,
handle_parsing_errors=True)
WebSocket Connection
The agent connects via Socket.IO, subscribes to its channel, and dispatches incoming
user messages to the LangChain executor:
import socketio
from langchain_core.messages import HumanMessage
sio = socketio.Client(reconnection=False)
chat_history = []
def handle_user_message(text):
"""Process a user message through the LangChain agent."""
chat_history.append(HumanMessage(content=text))
executor.invoke({"input": text, "chat_history": chat_history})
seen = set()
@sio.on("message:created", namespace="/ws")
def on_message(data):
if data.get("id") in seen or data.get("senderType") != "user":
return
seen.add(data["id"])
text = (data.get("text") or "").strip()
if text:
handle_user_message(text)
WebSocket Authentication
Placet WebSocket supports API key authentication for agents. Pass your API key
directly in the connection’s auth object:
sio = socketio.Client(reconnection=False)
sio.connect(PLACET_URL, namespaces=["/ws"],
auth={"apiKey": API_KEY}, transports=["websocket"])
sio.wait()