Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions agent/src/lead_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,11 @@ def update_lead(
def insert_lead(self, lead: dict[str, Any]) -> Optional[dict[str, Any]]:
"""Create a new lead. Returns the new row (with id/url filled), or None on failure."""

def add_comment(
self, lead_id: str, body: str
) -> Optional[dict[str, Any]]:
"""Post a comment on the lead. Returns a `{ok: True, ...}` dict on success, None on failure."""

def database_title(self) -> str:
"""Human label for the source — surfaces in the canvas's sync banner."""

Expand Down Expand Up @@ -95,6 +100,16 @@ def insert_lead(self, lead: dict[str, Any]) -> Optional[dict[str, Any]]:

return insert_lead(self.database_id, lead)

def add_comment(
self, lead_id: str, body: str
) -> Optional[dict[str, Any]]:
from .notion_integration import add_lead_comment

result = add_lead_comment(lead_id, body)
if result is None:
return None
return {"ok": True, "comment_id": result.get("id", ""), "source": "notion"}

def database_title(self) -> str:
# Tries the live API first (handles renames cleanly), falls back to
# the well-known v2a name so the banner reads cleanly even if the
Expand Down Expand Up @@ -232,6 +247,37 @@ def insert_lead(self, lead: dict[str, Any]) -> Optional[dict[str, Any]]:
self._write_all(rows)
return new_row

def add_comment(
self, lead_id: str, body: str
) -> Optional[dict[str, Any]]:
"""Append a comment to the lead row in the local JSON file.

The local store has no Notion to post to, so the "send" gesture
instead persists a comment record on the lead itself. This keeps
the demo flow working end-to-end without Notion configured.
"""
if not lead_id:
return None
comment = {
"id": str(uuid.uuid4()),
"body": body or "",
"created_at": datetime.now(timezone.utc).isoformat(),
}
with _FILE_LOCK:
rows = self._read_all()
for i, row in enumerate(rows):
if row.get("id") == lead_id:
existing = list(row.get("comments") or [])
existing.append(comment)
rows[i] = {**row, "comments": existing}
self._write_all(rows)
return {
"ok": True,
"comment_id": comment["id"],
"source": "local",
}
return None

def database_title(self) -> str:
return "Local: starter data"

Expand Down
37 changes: 37 additions & 0 deletions agent/src/notion_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@

from .notion_mcp import (
has_notion_token,
mcp_create_comment,
mcp_create_page,
mcp_fetch_data_source,
mcp_fetch_database_schema,
Expand Down Expand Up @@ -508,6 +509,42 @@ def update_lead(
return {"id": page_id, **(patch or {})}


# Notion's rich_text blocks cap at 2000 chars each. Chunk long messages so
# multi-paragraph email drafts don't get truncated by the API.
_RICH_TEXT_CHUNK = 1900


def _chunk_rich_text(body: str) -> List[Dict[str, Any]]:
"""Split `body` into Notion rich_text blocks under the per-block cap."""
if not body:
return [{"type": "text", "text": {"content": ""}}]
chunks: List[Dict[str, Any]] = []
remaining = body
while remaining:
head, remaining = remaining[:_RICH_TEXT_CHUNK], remaining[_RICH_TEXT_CHUNK:]
chunks.append({"type": "text", "text": {"content": head}})
return chunks


def add_lead_comment(page_id: str, body: str) -> Optional[Dict[str, Any]]:
"""Post `body` as a comment on a Notion lead page.

Returns the Notion Comment payload on success, or `None` when the
integration is unconfigured / the page id is missing / the API
errors. Used by the "Send" button on the inline email-draft card.
"""
if not has_notion_token():
return None
if not page_id:
print("add_lead_comment: page_id is required")
return None
try:
return mcp_create_comment(page_id, _chunk_rich_text(body or ""))
except Exception as e: # noqa: BLE001 - surface to caller as None
print(f"add_lead_comment: mcp_create_comment raised: {e}")
return None


def insert_lead(
database_id: str, lead: Dict[str, Any]
) -> Optional[Dict[str, Any]]:
Expand Down
25 changes: 25 additions & 0 deletions agent/src/notion_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,31 @@ def mcp_create_page(
)


def mcp_create_comment(
page_id: str, rich_text: List[Dict[str, Any]]
) -> Dict[str, Any]:
"""Post a comment on a Notion page via `API-create-a-comment`.

`rich_text` follows Notion's rich-text shape:
[{"type": "text", "text": {"content": "..."}}, ...]
Notion enforces a 2000-char cap per text block; the caller chunks
long bodies before passing them in.

Returns the new Comment object on success; raises on transport / 4xx.
Used by `notion_integration.add_lead_comment` to surface email drafts
on the lead's Notion page.
"""
return _extract_payload(
_run_sync(_call_tool_async(
"API-create-a-comment",
{
"parent": {"page_id": page_id},
"rich_text": rich_text,
},
))
)


def has_notion_token() -> bool:
"""Sentinel for callers that want to short-circuit before spawning npx."""
return _has_token()
111 changes: 111 additions & 0 deletions agent/src/notion_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -517,6 +517,115 @@ def insert_notion_lead(
)


@tool
def comment_on_lead(
lead_id: Annotated[str, "Notion page id of the lead to comment on."],
subject: Annotated[
str,
"Email subject. Rendered as the comment's first line (bold-prefixed).",
],
body: Annotated[
str,
"Email body. Posted verbatim under the subject line. Multi-paragraph "
"input is preserved; chunked across blocks if it exceeds Notion's "
"2000-char per-block cap.",
],
tool_call_id: Annotated[str, InjectedToolCallId] = "",
state: Annotated[Dict[str, Any], InjectedState] = None,
) -> Command:
"""Send the drafted outreach email by posting it as a comment on the lead's Notion page.

Wired to the "Send" button on `renderEmailDraft`'s inline card. The
comment lands on the Notion page identified by `lead_id` (or, when
Notion isn't configured, persists on the lead row in the local JSON
cache so the demo flow still completes end-to-end).

Returns a Command(update=) emitting a one-line confirmation:
"Sent email to <name> (commented on Notion page)." on Notion success
"Sent email to <name> (saved to local store)." on local success
"Send failed for lead <id>: <hint>" on failure
"""
try:
from .lead_store import get_store

if not lead_id:
return Command(
update={
"messages": [
ToolMessage(
content="Send failed: lead_id is required.",
tool_call_id=tool_call_id,
)
],
}
)

# Compose the comment text. Subject as a bold-prefix-style first line
# makes the comment readable at a glance in the Notion sidebar.
text = f"📨 {subject}".strip() if subject else "📨 (no subject)"
if body:
text = f"{text}\n\n{body}"
Comment on lines +563 to +567

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The implementation of the comment text does not match the description in the docstring and parameter annotations. The docstring states the subject is rendered as a 'bold-prefixed' first line, but the code only prepends an emoji and concatenates the strings without applying any Notion rich-text formatting (like bold: true). If the intention is to have a bold subject, the _chunk_rich_text utility in notion_integration.py would need to be updated to support annotations.


store = get_store()
result = store.add_comment(lead_id, text)
if result is None:
store_hint = (
"Local store write failed — check that agent/data is writable."
if store.is_local()
else (
"Notion call errored. Check NOTION_TOKEN, that the "
"database is shared with the integration, and that "
"the lead_id is a real Notion page id."
)
)
return Command(
update={
"messages": [
ToolMessage(
content=f"Send failed for lead {lead_id}: {store_hint}",
tool_call_id=tool_call_id,
)
],
}
)

# Resolve a friendly display name from canvas state so the agent's
# confirmation reads naturally.
leads_raw = (state or {}).get("leads") or []
display_name = "lead"
for l in leads_raw:
if isinstance(l, dict) and l.get("id") == lead_id:
display_name = l.get("name") or display_name
break

where = (
"saved to local store"
if (result.get("source") == "local")
else "commented on Notion page"
)
return Command(
update={
"messages": [
ToolMessage(
content=f"Sent email to {display_name} ({where}).",
tool_call_id=tool_call_id,
)
],
}
)
except Exception as e: # noqa: BLE001 - surface error text to the LLM
return Command(
update={
"messages": [
ToolMessage(
content=f"Send failed for lead {lead_id}: {e}",
tool_call_id=tool_call_id,
)
],
}
)


def load_notion_tools() -> List[Any]:
"""Return the Notion-flavored backend tool list for the agent.

Expand All @@ -527,6 +636,7 @@ def load_notion_tools() -> List[Any]:
- `notion_health_check`
- `update_notion_lead` (phase 04 — Command(update=) write-back)
- `insert_notion_lead` (phase 04 — Command(update=) write-back)
- `comment_on_lead` (email-draft Send button → Notion comment)

The Notion MCP server is spawned per-call inside `notion_mcp.py`, so
no setup happens here — the only env this function depends on is
Expand All @@ -539,6 +649,7 @@ def load_notion_tools() -> List[Any]:
notion_health_check,
update_notion_lead,
insert_notion_lead,
comment_on_lead,
]
print(f"Backend tools loaded: {len(tools)} tools")
return tools
Expand Down
16 changes: 14 additions & 2 deletions agent/src/prompts.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,16 @@
"- insert_notion_lead(lead): create a NEW lead row in Notion AND append\n"
" it to canvas state. `lead` is the full Lead shape (no id/url —\n"
" Notion assigns those). Reply is 'Added <name> to Notion (<id>).'\n"
"- comment_on_lead(lead_id, subject, body): post the drafted outreach\n"
" email as a comment on the lead's Notion page. Wired to the Send\n"
" button on the renderEmailDraft inline card. Use this whenever the\n"
" user clicks Send (\"Send the email to <name> (id <leadId>) by\n"
" commenting on their Notion page. Subject: ... Body: ...\") or\n"
" explicitly asks to send / post / deliver / publish the drafted\n"
" email. The tool reply is 'Sent email to <name> (commented on\n"
" Notion page).' on success — relay it as-is. On the local-store\n"
" fallback (no Notion configured) the comment lands on the lead row\n"
" in the local JSON; the reply will say 'saved to local store'.\n"
"- notion_health_check(): one-shot connection + schema sanity check.\n"
" Returns {user_id, db_title, row_count, expected_props,\n"
" actual_props, missing_props, error}. Call before claiming an\n"
Expand Down Expand Up @@ -294,8 +304,10 @@
" 'casual' | 'technical' | 'founder-to-founder' | 'conference-followup'\n"
" based on the lead's role / tools / company stage. Optionally include\n"
" a short `rationale` so the user sees why you chose that tone. The\n"
" card has Regenerate and Queue buttons that round-trip back to you —\n"
" do NOT queue or send in the same turn as renderEmailDraft.\n"
" card has Regenerate and Send buttons that round-trip back to you —\n"
" do NOT call comment_on_lead in the same turn as renderEmailDraft.\n"
" When the user clicks Send the next user message will instruct you\n"
" to call comment_on_lead with the leadId / subject / body verbatim.\n"
"- DO NOT call any render tool when state.leads is empty. If you don't\n"
" know whether leads are loaded, ask the user to import first or call\n"
" fetch_notion_leads yourself if the request implies it.\n"
Expand Down
42 changes: 29 additions & 13 deletions src/app/components/email-draft-review.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@
* The agent's `renderEmailDraft` frontend tool drops an `EmailDraftCard`
* into the chat stream when the user says "draft / write / compose an
* email to <name>". Tone is one of four; the card has Regenerate and
* Queue actions that round-trip back to the agent via `injectPrompt`.
* Send actions that round-trip back to the agent via `injectPrompt`.
* Send posts the subject + body as a comment on the lead's Notion page
* (or to the local store when Notion isn't configured).
*
* The agent prompt for this surface lives in
* `agent/src/prompts.py::GENERATIVE_UI_PROMPT` under the
Expand Down Expand Up @@ -90,6 +92,13 @@ export function EmailDraftReview() {
lead={DEMO_LEAD}
draft={variant}
variant="compact"
// Showcase wiring: Regenerate + Send mirror the live chat
// surface so the demo accurately reflects what the user
// sees when the agent renders this card. Both are no-ops
// here — the live wiring lives in LiveEmailDraft on the
// canvas page.
onRegenerate={() => undefined}
onSend={() => undefined}
/>
</div>
))}
Expand All @@ -107,9 +116,9 @@ export function EmailDraftReview() {
name: "renderEmailDraft",
description:
"Render a draft outreach email inline in chat. " +
"Use AFTER drafting, BEFORE queueing — the user opens it to edit. " +
"Side effects (queue, send) happen via the component's buttons, " +
"which call other tools. Do NOT call queueEmail in the same turn.",
"Use AFTER drafting, BEFORE sending — the user opens it to edit. " +
"Side effects (regenerate, send) happen via the component's buttons, " +
"which call other tools. Do NOT call comment_on_lead in the same turn.",
parameters: z.object({
leadId: z.string(),
leadName: z.string().optional(),
Expand All @@ -131,15 +140,15 @@ export function EmailDraftReview() {
<ReviewCodeBlock>{`- renderEmailDraft({leadId, draft: {subject, body, tone, rationale?}}):
inline draft outreach email. Call this WHENEVER the user says
"draft / write / compose / send an email to <name>" or asks for an
outreach message for a specific lead. Resolve <name> against
state.leads to get the leadId — match on full or partial name
(case-insensitive) and prefer the highest-confidence hit. Compose
subject + body yourself; pick \`tone\` from
outreach message for a specific lead. Resolve <name> by calling
find_lead(query='<name>') FIRST, then pass the returned id through as
leadId. Compose subject + body yourself; pick \`tone\` from
'casual' | 'technical' | 'founder-to-founder' | 'conference-followup'
based on the lead's role / tools / company stage. Optionally include
a short \`rationale\` so the user sees why you chose that tone. The
card has Regenerate and Queue buttons that round-trip back to you —
do NOT queue or send in the same turn as renderEmailDraft.`}</ReviewCodeBlock>
based on the lead's role / tools / company stage. The card has
Regenerate and Send buttons that round-trip back to you — Send fires
comment_on_lead(lead_id, subject, body), which posts the email as a
comment on the lead's Notion page. Do NOT call comment_on_lead in the
same turn as renderEmailDraft.`}</ReviewCodeBlock>
</ReviewLabel>
</div>
</ReviewSubsection>
Expand Down Expand Up @@ -174,7 +183,14 @@ export function EmailDraftReview() {
draft={args.draft}
variant="compact"
onRegenerate={() => injectPrompt(\`Regenerate the outreach email for \${lead.name}\`)}
onQueue={() => injectPrompt(\`Queue the email for \${lead.name} into the send queue.\`)}
onSend={() => injectPrompt(
\`Send the email to \${lead.name} (id \${lead.id}) by commenting on \` +
\`their Notion page. Call comment_on_lead with: \${JSON.stringify({
lead_id: lead.id,
subject: args.draft.subject,
body: args.draft.body,
})}\`,
)}
/>
);
}`}</ReviewCodeBlock>
Expand Down
Loading