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.
Collapsible settings (0.6.2+)
Settings render inside a fully-collapsible accordion so a long list doesn't crowd the conversation. In full-page chat mode it's a "Settings (N)" panel that defaults collapsed when there are many settings (open when there are only a few). In a sidecar the app's own inputs are the primary interface, so the "Inputs (N)" panel defaults open and can be tucked to a header to give the chat more room — and collapsing the chat panel expands the inputs to full height.
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.
run_app returns a summary of what the run produced — each output slot and
its new value (a figure's traces + title, a table's shape, a text preview) — so
the assistant can see the result of the run it triggered and react to it (e.g.
notice an empty chart and adjust the inputs), not just fire it and move on. The
callback still runs exactly once per run_app.
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. Because an output can
derive from that secret, run_app's result summary reports shape/type
only (no values) when the app has any secret input, so the secret can't
slip back to the model through the run report either.
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.