diff --git a/backend/apps/agents/agents.py b/backend/apps/agents/agents.py
index 5544739a..730882c2 100644
--- a/backend/apps/agents/agents.py
+++ b/backend/apps/agents/agents.py
@@ -67,6 +67,20 @@ async def list_sessions(dashboard_id: str = ""):
sessions = agent_manager.get_all_sessions(dashboard_id=dashboard_id or None)
return {"sessions": [p_session_list_item(s) for s in sessions]}
+@agents.router.get("/sessions/{session_id}/followups")
+async def predict_followups_route(session_id: str, count: int = 3):
+ """Chat-specific next-message suggestions in the user's own voice. Empty until the
+ conversation has >= 2 real exchanges; always empty rather than erroring."""
+ session = agent_manager.sessions.get(session_id)
+ if not session:
+ try:
+ session = await agent_manager.resume_session(session_id)
+ except ValueError:
+ raise HTTPException(status_code=404, detail="session not found")
+ from backend.apps.agents.manager.predict_followups import predict_followups
+ return {"suggestions": await predict_followups(session, count=max(1, min(count, 5)))}
+
+
@agents.router.get("/predict-prompts")
async def predict_prompts_route(count: int = 5):
"""Guess a few prompts the user might type next, in their own voice, from what they've already
diff --git a/backend/apps/agents/manager/predict_followups.py b/backend/apps/agents/manager/predict_followups.py
new file mode 100644
index 00000000..9c4601be
--- /dev/null
+++ b/backend/apps/agents/manager/predict_followups.py
@@ -0,0 +1,92 @@
+"""Aux-LLM follow-up prediction for ONE chat: guess the user's next message in THIS conversation,
+in their exact voice, from the conversation itself. Sibling of predict_prompts.py (which predicts
+across chats from topic history); this one only ever reads the given session. Provider-agnostic
+cheap tier; fail-open to [] so the chat renders nothing instead of an error."""
+
+import logging
+from typing import List
+
+from typeguard import typechecked
+
+from backend.apps.agents.core.aux_llm import aux_max_tokens_for
+from backend.apps.agents.core.models import AgentSession
+from backend.apps.agents.manager.predict_prompts import parse_suggestion_lines
+from backend.apps.agents.manager.session.history_compaction import get_branch_messages
+
+logger = logging.getLogger(__name__)
+
+MAX_FOLLOWUPS = 3
+# No suggestions until the conversation has a real shape: below two full exchanges any guess is
+# generic filler, and the empty-chat starters already cover turn zero.
+MIN_EXCHANGES = 2
+# Enough tail to know where the conversation is, small enough to stay a sub-cent aux call.
+P_TAIL_MESSAGES = 12
+P_PER_MESSAGE_CAP = 700
+
+
+@typechecked
+def followups_eligible(session: AgentSession) -> bool:
+ """True once this branch holds >= MIN_EXCHANGES completed user->assistant exchanges."""
+ msgs = get_branch_messages(session)
+ users = sum(1 for m in msgs if m.role == "user" and not getattr(m, "hidden", False))
+ assistants = sum(1 for m in msgs if m.role == "assistant")
+ return min(users, assistants) >= MIN_EXCHANGES
+
+
+def conversation_tail(session: AgentSession) -> str:
+ lines: List[str] = []
+ for m in get_branch_messages(session)[-P_TAIL_MESSAGES:]:
+ if getattr(m, "hidden", False) or m.role not in ("user", "assistant"):
+ continue
+ text = m.content if isinstance(m.content, str) else str(m.content)
+ if len(text) > P_PER_MESSAGE_CAP:
+ text = text[:P_PER_MESSAGE_CAP] + "..."
+ lines.append(f"{'User' if m.role == 'user' else 'Assistant'}: {text}")
+ return "\n".join(lines)
+
+
+@typechecked
+async def predict_followups(session: AgentSession, count: int = MAX_FOLLOWUPS) -> List[str]:
+ """Up to `count` plausible next messages for THIS chat, in the user's voice. [] on any miss."""
+ try:
+ if not followups_eligible(session):
+ return []
+ from backend.apps.settings.credentials import get_anthropic_client_for_model
+ from backend.apps.agents.providers.registry import resolve_aux_model
+ from backend.apps.settings.settings import load_settings
+
+ global_settings = load_settings()
+ tail = conversation_tail(session)
+ if not tail:
+ return []
+ aux_model = (await resolve_aux_model(global_settings, preferred_tier="haiku"))[0]
+ client = get_anthropic_client_for_model(global_settings, aux_model)
+
+ system_prompt = (
+ "You predict the next message a user might send in an ONGOING conversation with their "
+ "AI agent. You never answer or explain; you only produce plausible follow-ups the USER "
+ "would type next in THIS conversation.\n\n"
+ "Mimic the user's exact writing style from their messages in the transcript: their "
+ "casing, punctuation, brevity, slang. If they write lowercase two-word asks, so do you.\n\n"
+ f"Return exactly {count} follow-ups, one per line, no numbering, no quotes, no preamble. "
+ "Each under ~80 characters, each a DIFFERENT direction (dig deeper, next step, adjacent "
+ "ask), each specific to this conversation's actual content, never generic."
+ )
+ user_turn = (
+ "Conversation so far:\n\n" + tail + "\n\n\n"
+ f"Predict {count} messages this user might send next."
+ )
+
+ chunks: List[str] = []
+ async with client.messages.stream(
+ model=aux_model,
+ max_tokens=aux_max_tokens_for(aux_model, base=200),
+ system=system_prompt,
+ messages=[{"role": "user", "content": user_turn}],
+ ) as stream:
+ async for text in stream.text_stream:
+ chunks.append(text)
+ return parse_suggestion_lines("".join(chunks), count)
+ except Exception as e:
+ logger.info(f"[predict-followups] fail-open ([]): {e}")
+ return []
diff --git a/backend/apps/agents/manager/predict_prompts.py b/backend/apps/agents/manager/predict_prompts.py
index dc478ceb..fa316efd 100644
--- a/backend/apps/agents/manager/predict_prompts.py
+++ b/backend/apps/agents/manager/predict_prompts.py
@@ -46,7 +46,7 @@ def p_recent_topics(limit: int = MAX_TOPICS) -> List[str]:
return topics
-def p_parse_lines(raw: str, count: int) -> List[str]:
+def parse_suggestion_lines(raw: str, count: int) -> List[str]:
"""One suggestion per line; strip bullets/numbering/quotes, drop empties, cap at count."""
out: List[str] = []
for line in raw.splitlines():
@@ -115,7 +115,7 @@ async def predict_prompts(count: int = MAX_SUGGESTIONS) -> List[str]:
) as stream:
async for text in stream.text_stream:
chunks.append(text)
- return p_parse_lines("".join(chunks), count)
+ return parse_suggestion_lines("".join(chunks), count)
except Exception as e:
logger.info(f"[predict-prompts] fail-open ([]): {e}")
return []
diff --git a/backend/tests/test_predict_followups.py b/backend/tests/test_predict_followups.py
new file mode 100644
index 00000000..246f81fd
--- /dev/null
+++ b/backend/tests/test_predict_followups.py
@@ -0,0 +1,51 @@
+"""The turn gate is the product rule (no suggestions until the chat has real shape), so it is
+pinned independently of the aux call, which is fail-open and never exercised here."""
+
+from backend.apps.agents.core.models import AgentSession, Message
+from backend.apps.agents.manager.predict_followups import followups_eligible, conversation_tail
+
+
+def p_session(*roles: str) -> AgentSession:
+ s = AgentSession(name="t", model="sonnet")
+ s.messages = [Message(role=r, content=f"m{i}", branch_id="main") for i, r in enumerate(roles)]
+ return s
+
+
+def test_empty_chat_is_not_eligible():
+ assert followups_eligible(p_session()) is False
+
+
+def test_one_exchange_is_not_eligible():
+ assert followups_eligible(p_session("user", "assistant")) is False
+
+
+def test_two_exchanges_are_eligible():
+ assert followups_eligible(p_session("user", "assistant", "user", "assistant")) is True
+
+
+def test_unanswered_user_spam_is_not_eligible():
+ assert followups_eligible(p_session("user", "user", "user", "user")) is False
+
+
+def test_hidden_user_turns_do_not_count():
+ s = p_session("user", "assistant", "user", "assistant")
+ s.messages[2].hidden = True
+ assert followups_eligible(s) is False
+
+
+def test_tool_noise_does_not_count_as_exchanges():
+ s = p_session("user", "tool_call", "tool_result", "assistant", "tool_call", "assistant")
+ assert followups_eligible(s) is False
+
+
+def test_tail_contains_only_visible_user_assistant_text():
+ s = p_session("user", "tool_call", "assistant")
+ tail = conversation_tail(s)
+ assert "User: m0" in tail and "Assistant: m2" in tail and "m1" not in tail
+
+
+def test_tail_caps_giant_messages():
+ s = p_session("user", "assistant")
+ s.messages[0].content = "x" * 5000
+ tail = conversation_tail(s)
+ assert len(tail) < 2000 and tail.count("...") >= 1
diff --git a/frontend/src/app/pages/AgentChat/AgentChat.tsx b/frontend/src/app/pages/AgentChat/AgentChat.tsx
index ad380fb4..5ff38fbb 100644
--- a/frontend/src/app/pages/AgentChat/AgentChat.tsx
+++ b/frontend/src/app/pages/AgentChat/AgentChat.tsx
@@ -69,6 +69,7 @@ import ForceStopAgentBar from './ForceStopAgentBar';
import { RateLimitPill } from './shell/RateLimitPill';
import { ContextRecoveredPill } from './shell/ContextRecoveredPill';
import ChatInput, { ChatInputHandle } from './ChatInput';
+import FollowupChips from './FollowupChips';
import ContextDrawer from './shell/ContextDrawer';
import { ErrorSlime } from '@/app/components/feedback/ErrorSlime';
import { ContextPath } from '@/app/components/editor/DirectoryBrowser';
@@ -2434,6 +2435,13 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose
+ handleSend(p)}
+ />
void;
+}
+
+// Chat-specific follow-ups in the user's own voice, Claude-style chips above the composer. The
+// backend stays silent until the chat has >= 2 real exchanges, so rendering [] as nothing IS the
+// turn gate; clicking sends immediately (the text is already written the way the user types).
+const FollowupChips: React.FC = ({ sessionId, busy, messageCount, enabled, onPick }) => {
+ const c = useClaudeTokens();
+ const [suggestions, setSuggestions] = useState([]);
+ const fetchSeqRef = useRef(0);
+
+ useEffect(() => {
+ if (!sessionId || !enabled || busy) {
+ setSuggestions([]);
+ return undefined;
+ }
+ const seq = ++fetchSeqRef.current;
+ // Small settle delay so the fetch reads the turn's final transcript, not a mid-commit state.
+ const timer = setTimeout(async () => {
+ try {
+ const tok = (() => { try { return getAuthToken(); } catch { return ''; } })();
+ const headers: Record = {};
+ if (tok) headers['Authorization'] = `Bearer ${tok}`;
+ const resp = await fetch(`${API_BASE}/agents/sessions/${sessionId}/followups?count=3`, { headers });
+ if (!resp.ok || seq !== fetchSeqRef.current) return;
+ const data = await resp.json();
+ if (seq !== fetchSeqRef.current) return;
+ setSuggestions(Array.isArray(data.suggestions)
+ ? data.suggestions.filter((s: unknown): s is string => typeof s === 'string' && !!s)
+ : []);
+ } catch { /* fail open: no chips */ }
+ }, 900);
+ return () => clearTimeout(timer);
+ }, [sessionId, enabled, busy, messageCount]);
+
+ if (suggestions.length === 0) return null;
+ return (
+
+ {suggestions.map((s) => (
+ { setSuggestions([]); onPick(s); }}
+ sx={{
+ px: 1.25,
+ py: 0.5,
+ borderRadius: 999,
+ border: `1px solid ${c.border.medium}`,
+ bgcolor: c.bg.surface,
+ color: c.text.secondary,
+ fontSize: '0.8125rem',
+ lineHeight: 1.4,
+ cursor: 'pointer',
+ userSelect: 'none',
+ maxWidth: '100%',
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ whiteSpace: 'nowrap',
+ transition: 'border-color 0.15s ease, color 0.15s ease, background 0.15s ease',
+ '&:hover': { borderColor: c.border.strong, color: c.text.primary, bgcolor: c.bg.elevated },
+ }}
+ >
+ {s}
+
+ ))}
+
+ );
+};
+
+export default FollowupChips;