Chat apps
Pass chat=True and your callback becomes a streaming chat app — a composer
pinned at the bottom, a scrolling transcript, and per-session conversation
history — with no LLM provider baked in. You bring the model; Fast Dash brings
the UI.
The 5-line chatbot
The callback's first parameter must be named query (it receives the composer
text). yield strings to stream the reply token by token:
from fast_dash import fastdash
@fastdash(chat=True)
def assistant(query: str):
"""A helpful assistant."""
for token in my_llm.stream(query): # any provider — you choose
yield token
That is the whole app: a bottom-anchored composer, a streaming reply rendered as markdown, and a transcript. Press Enter to send, Shift+Enter for a new line.
No vendor lock-in
Fast Dash never bundles an LLM SDK. my_llm above is your code calling
whatever you like — OpenAI, Anthropic, a local model, a plain function.
Conversation history
Declare a history parameter and Fast Dash injects the prior messages of the
current browser session before each turn — a list of
{"role": "user" | "assistant", "content": str}:
@fastdash(chat=True)
def assistant(query: str, history: list):
messages = [{"role": m["role"], "content": m["content"]} for m in history]
messages.append({"role": "user", "content": query})
for token in my_llm.stream(messages):
yield token
History is kept per browser session, server-side, and bounded (default 50
turns; set chat_history_size=...).
Developer-declared settings
Any parameter other than query, history, and ctx renders as a setting the
user sets manually, using the same type-hint inference as a regular Fast Dash
app — dropdowns, number inputs, switches, even a dataset upload:
from typing import Literal
from fast_dash import fastdash, Upload
@fastdash(chat=True)
def assistant(
query: str,
model: Literal["gpt-4", "claude", "gemini"] = "claude",
temperature: float = 0.7,
dataset: Upload = None,
):
... # model / temperature / dataset are passed to every turn
model becomes a dropdown, temperature a number input, dataset an upload
box. Their live values are passed to the callback each turn, rendered in the
sidebar above (or beside) the transcript.
The frame grammar
Yielding a str is sugar for a text frame. For richer replies, yield frame
dicts — every type below renders natively:
| Frame | Shape | Renders as |
|---|---|---|
content |
{"type": "content", "content": str} |
streamed markdown text |
reasoning |
{"type": "reasoning", "content": str} |
a collapsible "thinking" block |
tool_start |
{"type": "tool_start", "name": str, "args": dict, "id": str} |
a tool-call card (spinner) |
tool_end |
{"type": "tool_end", "name": str, "result": Any, "id": str} |
resolves the matching card |
artifact |
{"type": "artifact", "content": Figure \| DataFrame \| Image \| str} |
an inline artifact |
interrupt |
{"type": "interrupt", "action_requests": [...], "allowed_decisions": [...]} |
an approve/reject card |
error |
{"type": "error", "message": str} |
an error notice |
tool_start and tool_end are paired by their id (defaulting to name), so a
card opens with a spinner and resolves in place when the result arrives. Artifacts
materialize at turn completion. A bare str yield, a plain str return, and this
frame grammar can be mixed freely. Unknown frame types are ignored (with a
warning), never fatal; an exception raised inside the callback is caught, shown as
an error in the reply, and the session stays usable.
A richer example
import numpy as np
import plotly.graph_objects as go
from fast_dash import FastDash
def analyst(query: str, history: list):
yield {"type": "reasoning", "content": "Fetch the series, then plot it."}
yield {"type": "tool_start", "name": "fetch_series", "id": "t1",
"args": {"query": query}}
yield {"type": "tool_end", "name": "fetch_series", "id": "t1",
"result": {"rows": 50, "status": "ok"}}
yield "Here is the series you asked about: "
x = np.linspace(0, 12, 50)
yield {"type": "artifact", "content": go.Figure(go.Scatter(x=x, y=np.sin(x)))}
FastDash(callback_fn=analyst, title="Analyst", chat=True).run()
While a turn streams, the Send button becomes a Stop button; pressing it
cancels the turn and the partial reply is kept with a (stopped) marker.
LangGraph agents
Instead of a callback, chat=True accepts a compiled LangGraph graph or a
"module:attr" spec string (needs fast-dash[langstage]). The graph is bridged
to the frame grammar by langstage-core,
and multi-turn memory rides the graph's checkpointer keyed by the chat session:
from fast_dash import FastDash
# a compiled LangGraph graph, or "my_pkg.agents:graph"
FastDash(callback_fn="my_pkg.agents:graph", chat=True).run()
Typed agent events
A LangGraph agent's tool results are rendered as typed cards automatically.
When the agent calls a common tool, Fast Dash extracts a structured object from
the result and shows a purpose-built card instead of a raw tool blob: a
think_tool reflection collapses into a thinking block, write_todos becomes a
task list with status icons, and display_inline renders figures, tables, and
markdown inline. This is on by default (no configuration) for any LangGraph chat
agent; the seven built-ins cover think_tool, write_todos, memory,
skill_view, skill_manage, context compression, and display_inline.
import json
from langchain_core.tools import tool
@tool
def write_todos(todos: list) -> str:
"""Track the plan; renders as a task list with status icons."""
return json.dumps(todos) # [{"content": "...", "status": "completed"}, ...]
@tool
def display_inline(display_type: str, data) -> str:
"""Render rich content inline (markdown / table / figure)."""
return json.dumps({"display_type": "markdown", "data": "# Result\nDone."})
# Bind these tools to your LangGraph agent; FastDash(callback_fn=graph, chat=True)
# renders each write_todos call as a task list and each display_inline call inline.
To handle a tool the built-ins don't know, pass chat_extractors= -- an iterable
of objects with a tool_name, an extracted_type, and an extract(content)
method. They are appended to the defaults (an extractor whose tool_name matches
a built-in overrides it). A plain (query, ctx) chat callable ignores
chat_extractors=.
The ctx object
query and history are all the 5-line chatbot needs. Power features fold into
one optional ctx parameter (a ChatContext) instead of a growing list of magic
names — declare it to opt in:
def bot(query, history, ctx):
... # ctx.thread_id, ctx.resume
ctx.thread_id— the session id (the LangGraph checkpointer thread).ctx.resume— a decision answering a pending interrupt (HITL), elseNone.
Add an assistant to a normal app (a chat sidecar)
The sections above make the chat the app. The mirror image: keep a normal
Fast Dash app — typed inputs, real outputs, a Run button — and mount an
independent chat assistant beside it by passing your agent as chat=. The
assistant lives in the left sidebar, stacked under the inputs and collapsible;
the app keeps working on its own (set inputs, Run, read outputs). The agent is a
chat callback, a LangGraph graph, or a chat-model instance — exactly as for
full-page chat:
from fast_dash import FastDash
def dashboard(revenue: int = 100, region: str = "West") -> str:
"""A normal Fast Dash app."""
return f"{region}: ${revenue}"
FastDash(
callback_fn=dashboard, # your app, unchanged
chat=my_langgraph_agent, # graph | "module:attr" | (query, ctx) callback | model
chat_title="Assistant",
).run()
The agent shares nothing with the app's callback except the capabilities the
toolkit grants, all reached through ctx and drive frames:
- Read — declare
ctxandctx.inputsgives the agent the app's live input values{name: value}each turn.ctx.input_specsgives the app's input contract (types, options, bounds) — the same one an MCP agent sees. - Drive — the agent can
yield {"type": "set_input", "name": ..., "value": ...}to set a control,yield {"type": "run_app"}to run the app and refresh its outputs,yield {"type": "set_output", "slot": "A", "value": ...}to render a value into one output slot directly, andyield {"type": "set_layout", "mosaic": "AB"}to rearrange the existing output slots. Anything a user can do, the agent can do.
Let Fast Dash build the agent (agent_toolkit)
Pass a chat model (a model instance, a "provider:model" string, or chat=True
with chat_model=) and Fast Dash auto-builds a LangChain assistant wired to
your app. Its tools come from agent_toolkit(app) — read_app, set_input,
run_app, set_output, set_layout, and run_python — trimmed to the
chat_tools allowlist you pass (needs pip install "fast-dash[agent]"):
from fast_dash import FastDash
def dashboard(revenue: int = 100, region: str = "West") -> str:
return f"{region}: ${revenue}"
FastDash(
callback_fn=dashboard,
chat=True,
chat_model="openai:gpt-4o-mini", # or a model instance / FASTDASH_MODEL
chat_tools=("read_app", "set_input", "run_app"), # read + drive, no code exec
).run()
chat_tools=None (the default) enables the full toolkit — including
run_python, which runs Python in the app process with human-in-the-loop
approval. Narrow it to a tuple of tool-name strings (and/or RunPython(...)
configs). chat_tools=("read_app",) gives a read-only assistant that
converses and reads ctx.inputs but can't drive the app; chat_tools=() is a
chat with no app access at all.
To wire the toolkit into an agent you build yourself, call agent_toolkit(app)
for the tools and app_prompt(app) for a system prompt, or attach
FastDashMiddleware(app) to a LangChain create_agent.
The auto-trim rules keep the assistant safe by default: on an update_live app
every app-driving verb is dropped (its inputs recompute on change, so driving
would double-run the callback or be immediately overwritten — the assistant is
read-only there), and on a multi-function / steps app the toolkit trims to
read_app only. A chat-shaped callback_fn and an agent in chat= is
rejected — one adds a chat to an app, the other is the chat.
Bad inputs are handled: set_input is validated against the app's contract, so
an unknown input, a value outside an input's options, or a wrong-typed value is
refused with a message (and fed back to the model) rather than reaching the
callback. A user's Run always wins — it restores the default output layout,
so set_layout / set_output changes never outlive a manual Run. Pressing
Stop mid-turn stops immediately — any input the agent had already set stays
set (Stop means "stop now", not "undo").
Password inputs are never exposed
A PasswordInput's value is redacted from ctx.inputs, omitted from
ctx.input_specs / app_tool_specs, and set_input on it is refused — so a
secret the user typed is never sent to the model and the agent can't set it.
run_app still runs the callback with the real value.
Large outputs
run_app's outputs are streamed to the browser like any other update; a
very large output (a big DataFrame or image) is a correspondingly large
payload per drive. Prefer paging or summarising heavy outputs the assistant
triggers frequently.
Backends
Streaming rides whatever transport the backend already uses, with no change to your callback:
- Flask (default): frames stream as socket.io events.
- ASGI (
backend="fastapi", needsfast-dash[fastapi]): frames are pushed with Dash's nativeset_propsover a WebSocket — no socket.io.
Human-in-the-loop (interrupts)
A LangGraph agent that calls interrupt(...) pauses the turn and Fast Dash
renders an approve / reject card (from the interrupt's allowed_decisions)
showing the requested action. The composer is held until you choose a decision;
clicking one resumes the same turn on its checkpoint — the agent continues from
where it paused. Multi-step approvals just pause again. (Resume is a LangGraph
capability, so the live decision buttons appear for langstage agents; a plain
generator that yields an interrupt frame renders the card as informational.)
Driving a chat app over MCP
mcp_server=True exposes the chat app to agents at /mcp:
describe_app()reports the composer contract (thequerystring) and any sidebarsettings.invoke(query=..., settings=...)runs one turn headlessly and returns its frames (JSON-safe); history and thread state advance across calls.
What chat mode does and doesn't allow
chat=True is a distinct interaction mode, so a few combinations are rejected at
startup with a clear message:
- the first parameter must be
query; update_live=True, multi-function apps, and steps apps are not supported;outputs=andstream=are ignored (the transcript is the output; streaming is always on).
The existing Chat output component (-> Chat) is unchanged
and still available for embedding a chat transcript as one output among several.