Take a resource (issue # or name) from "not implemented" to "shipped behind a
green agent-check," following the canonical conversations vertical as the
template.
Encode the stereotyped vertical-shipping motion as a single repeatable workflow
so the resource-specific bits are the only thing the human (or coding agent)
has to think about. This is the harness for issues #2, #4, #5, #14, and any
future client.<resource> surface.
-
Read the facts file. Open docs/api-facts.yaml and find the resource
under tags.<resource>. This single Read tells you: every generated module
name (with quirks like _a_ infix flagged), HTTP method + path, response
type, list shape (field_results / raw_array / single / mutation),
and whether helper/domain wiring already exists. If the resource isn't
listed, uv run poe facts to regenerate (rare — facts-check runs in CI).
-
Plan. Invoke the vertical-planner agent with the issue # or resource
name. The planner consults docs/api-facts.yaml first, then reads issue
context and the canonical conversations templates to produce a structured
plan. Do not proceed without a plan that names every generated module to
wrap and flags any quirks.
-
Domain projection. Create
frontapp_public_api_client/domain/<resource>.py — a Pydantic class extending
FrontappPydanticBase that projects the response model. For epoch-second
timestamps, mirror the pattern in domain/conversation.py: type the field as
AwareDatetime | None, then add a @field_validator(..., mode="before") that
calls a private _unix_to_datetime helper (also defined in that module).
domain/converters.py only carries the to_unset / unwrap_unset UNSET
helpers; it does not contain time conversion. Export from
domain/__init__.py.
-
Helper class. Create frontapp_public_api_client/helpers/<resource>.py
— a class extending Base with one async method per generated endpoint
(use tags.<resource>.endpoints[] from the facts file as the checklist).
Inside each method:
- Lazy-import the generated
api/<tag>/<module>.py and the domain class.
- Build kwargs dict (only set keys for non-None args, to keep
UNSET
defaults from the generated signature).
- Call
<module>.asyncio_detailed(**kwargs).
- Unwrap based on
list_shape from the facts file:
field_results → getattr(parsed, "field_results", None) or []
raw_array → unwrap(response) returns a list directly
single → unwrap_as(response, <ResponseType>)
mutation → is_success(response) for 202/204; otherwise unwrap_as
- Return Pydantic domain models, not generated attrs. Export from
helpers/__init__.py.
-
Wire into FrontappClient. In frontapp_client.py:
- Add
self._<resource>: <Class> | None = None in __init__.
- Add a lazy
@property named <resource> returning the helper, mirroring
the existing conversations property at line ~1098.
- Add the
TYPE_CHECKING import at the top.
-
MCP tools. Create
frontapp_mcp_server/src/frontapp_mcp/tools/<resource>.py mirroring
tools/conversations.py:
-
One <Resource>Summary Pydantic projection for compact LLM responses.
-
def register_tools(mcp: FastMCP) -> None: with one nested function per
tool, decorated with @mcp.tool(name=..., description=...).
-
Reads return a list[<Resource>Summary]. Mutations take
confirm: bool = False and return a plain dict with preview and
confirmed keys (matching tools/conversations.py:update_conversation
or tools/drafts.py:create_draft_reply). Customer-facing outbound
mutations always go through the drafts vertical; there is no direct-
send pattern.
-
Use confirm_or_preview for the gate. Every mutation tool runs
the same preview-then-elicit cascade — import confirm_or_preview
from frontapp_mcp.tools.schemas and call it once instead of
hand-rolling the 6-line if not confirm: ... require_confirmation ... ConfirmationResult.CONFIRMED block. Canonical shape:
preview = {"action": "...", ...}
gate = await confirm_or_preview(
context,
preview=preview,
confirm=confirm,
elicit_message=f"Concrete prompt the user sees?",
)
if gate is not None:
return gate
# ... proceed with the mutation
ConfirmationResult (a StrEnum of CONFIRMED/CANCELLED/
DECLINED) is now an internal detail of confirm_or_preview. Only
import it directly if your tool needs to branch differently between
declined vs cancelled (rare).
-
Tools call get_services(context).client.<resource>.<method>(...). Never
reach into the generated api/ modules from a tool.
-
Register tools. In
frontapp_mcp_server/src/frontapp_mcp/tools/__init__.py:register_all_tools,
add from .<resource> import register_tools as register_<resource>_tools
then register_<resource>_tools(mcp).
-
Cache + instructions. In
frontapp_mcp_server/src/frontapp_mcp/server.py:
- Append the new read-tool names to
_READ_ONLY_TOOLS.
- Extend the
instructions= block with a section for the new resource —
domain model description + tool selection guide + any search-syntax
differences.
-
Help resource. Update
frontapp_mcp_server/src/frontapp_mcp/resources/help.py Markdown to cover
the new tools and parameters. This is hand-maintained — do not assume the
tool docstrings are enough.
-
Tests. Add helper tests at tests/test_<resource>.py (mock httpx
transport, exercise the unwrap helpers end-to-end) and MCP tool tests at
frontapp_mcp_server/tests/test_<resource>_tools.py. Use
helpers/conversations.py and existing tests/test_conversations.py as
templates.
-
README. Flip the API Coverage row from ⏳ to ✅.
-
Validate. uv run poe full-check. This includes facts-check, so
if you somehow regenerated the client during this work, the facts file
must also be regenerated (uv run poe facts). Fix all errors at the
source — no noqa, no type: ignore, no skips.