Update Python Server SDK tech docs - #667
Conversation
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…tool response Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ingual, swaig_secret Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Devon-White
left a comment
There was a problem hiding this comment.
Review: verified against signalwire-python @ f7fec89 (v3.4.1)
I pulled the SDK fresh. It has moved two releases past this PR's sync point: v3.4.0 (2026-08-24) and v3.4.1 (2026-09-06). Per the 3.4.0 changelog, "3.3.0 was never tagged or documented, so its contents are folded in here" — so the 3.3.0 surface this PR documents is real, but there is post-3.3.0 drift.
What I verified as correct
Method-by-method against source, plus an automated signature/default sweep:
HandoffRouter— all 5 methods, ctor defaultsnonce_ttl=3600/max_messages_per_call=200/capture_timeout=8.0(ai_chat/handoff.py:92-94), route status codes,NonceEntryexported fromsignalwire.ai_chat.AgentBase.mount(core/mixins/web_mixin.py:229),add_per_call_config(:1500),on_call_end(core/agent_base.py:557),set_multilingual(core/mixins/ai_config_mixin.py:228).FunctionResult—hold()incl. theisinstance(prompt, int)back-compat branch and the bare-integer-unless-routing emission (core/function_result.py:518-594),set_tool_response,rpc_ai_messageValueError,rpc_ai_global_data.core.capabilitiesandcore.post_prompt— including the voice/chat divergence table, which matches the module docstring verbatim.ContextBuilder.validate()— the newinitial_step, reserved-name, and dangling-set_functionschecks, incl.RESERVED_NATIVE_TOOL_NAMES = {next_step, change_context, gather_submit}(core/contexts.py:1243).- Env vars — all 9 new names exist in source;
SIGNALWIRE_TOKENhas 0 hits,SIGNALWIRE_API_TOKENhas 18, so thebuiltin-skills.mdxfix is right. - REST —
RequestOptionsfields/defaults, the POST/PATCH retry asymmetry (rest/_request_options.py:138-160),PaginatedIterator's empty-page and cycle-guard behavior,Messages/Projects,client.compatgenuinely gone,SignalWireClient→RestClient(rest/__init__.py),area_code→areacode(confirmed againstspecs/signalwire-rest/relay-rest/phone-numbers/main.tsp:111), andclient.callingreally is 37 methods.
Automated param-name/default comparison found zero mismatches across 54 relay Call pages, 76 AgentBase pages, and 51 FunctionResult pages. Every newly added /docs/… link and #anchor fragment resolves. yarn fern-md-check passes (2790 files); yarn fern-check fails only the unauthenticated FDR redirects check, as noted in the PR body. No changelog entry — correct, this is documentation work. Nav needs no edit (folder-based). The branch touches no specs/, so there's no build:specs obligation.
Findings
9 inline comments below. Three are post-3.3.0 SDK drift, one is a substantive accuracy bug, the rest are consistency/wording.
Two items I couldn't anchor inline
Both files are outside this PR's diff:
reference/python/agents/cli/sw-search.mdx needs the same two fixes as search/helpers.mdx:
- Lines 105 and 194 say
"spacy"is "Better quality, slower." The SDK deleted that claim in 3.4.1 — see the inline comment onsearch/helpers.mdx. --min-chunk-sizeis missing entirely (cli/build_search.py:256), and--chunk-sizegained markdown-specific semantics the page doesn't mention. The SDK's own hand-maintained help text now reads: "Chunk size in words (default: 50). For the markdown strategy this is the split threshold, applied as chunk_size * 6 characters." The implementation ismin_chars = max(0, self.min_chunk_size * 6)atsearch/document_processor.py:476.
reference/python/agents/swaig-function/{execute,to-swaig}.mdx have the same normalization gap as the two gather pages: execute(raw_data) and to_swaig(token, call_id) are X | None = None in source but carry no default in the docs.
One process note
The branch is behind origin/main, so this PR's diff currently shows ~200 files it doesn't own (specs/**/tsp-output, fern/apis/*/openapi.yaml, changelog entries, scripts/). The branch's actual authored surface is 296 files, all under fern/products/server-sdks/pages/reference/python/** plus 3 guides, fern/docs.yml, and sdk-source-sync.json. Merging main again would make this much easier to review.
| "status": "active", | ||
| "synced_commit": "295745b24a4b69b92a4b536a7e7052d5128e71c8", | ||
| "synced_at": "2026-06-03" | ||
| "synced_commit": "7f11951c2623c5ec11178f553cbcb663dbe4bcb3", |
There was a problem hiding this comment.
This commit does not exist in signalwire/signalwire-python.
$ git cat-file -t 7f11951c2623c5ec11178f553cbcb663dbe4bcb3
fatal: git cat-file: could not get object infoIt was the head of a PR branch that got squash-merged. The squash commit is 8253718 feat(agent): handoff routes, capability declaration, post-prompt normalization (#92) — that's the reachable commit corresponding to the state this PR was written against.
This matters because it breaks the exact workflow this file's own _comment documents:
To check changes since last sync:
cd temp/signalwire-<variant> && git log <synced_commit>..HEAD --oneline
$ git log 7f11951c..HEAD --oneline
fatal: ambiguous argument '7f11951c...': unknown revision or path not in the working tree.Since the docs need a re-sync pass anyway (see the min_chunk_size and spacy comments), I'd point this at current SDK HEAD — short SHA f7fec89, tag v3.4.1 — and bump synced_at. Use 8253718 instead if you'd rather record the state actually verified rather than re-verifying against 3.4.1.
Either way the current value has to change: it resolves to nothing, so the next person to run the drift check gets a fatal error rather than a diff.
| @@ -76,7 +76,7 @@ also be called directly for custom search pipelines. | |||
| - `"spacy"` -- better quality, requires spaCy models | |||
There was a problem hiding this comment.
The SDK explicitly retracted this claim in 3.4.1. It's not just unsupported now — upstream deliberately replaced it.
cli/build_search.py before (what this line still reflects):
help="NLP backend for document processing: nltk (fast, default) or spacy (better quality, slower)",
cli/build_search.py:306-311 at f7fec89:
help=(
"NLP backend for document processing: nltk (fast, default) or "
"spacy (slower; also expands content with WordNet synonyms, whose "
"effect depends on your corpus and is written into the index -- "
"measure it before choosing, see preprocess_document_content)"
),
And for the query side (the --query-nlp-backend argument), which is what this page's nlp_backend field feeds:
help=(
"NLP backend for query processing: nltk (fast, default) or spacy "
"(slower; expands the query with WordNet synonyms, whose effect "
"depends on your corpus -- measure it, see preprocess_query)"
),
So spacy is not "better quality" — it's slower and applies WordNet synonym expansion whose benefit is corpus-dependent. On the index side the expansion is baked into the index, which makes the choice expensive to reverse. Recommending it as the quality option points readers at a decision the SDK now tells them to measure first.
| - `"spacy"` -- better quality, requires spaCy models | |
| - `"spacy"` -- slower; expands the query with WordNet synonyms. The effect depends on your corpus, so measure it rather than assuming it helps. Requires spaCy models. |
The same fix is needed at cli/sw-search.mdx:105 and :194, which are outside this diff.
| </ParamField> | ||
|
|
||
| <ParamField path="keyword_weight" type="Optional[float]" toc={true}> | ||
| <ParamField path="keyword_weight" type="float | None" default="None" toc={true}> |
There was a problem hiding this comment.
keyword_weight is a no-op — this description tells readers it does something it doesn't.
"When not set, the engine uses its internal max-signal-wins scoring" implies that when it is set, something different happens. It doesn't. The SDK says so outright in the docstring — search/search_engine.py:141-142:
keyword_weight: Accepted for API stability; scoring is max-signal-wins
with agreement boost, so this is no-op currently
Confirmed by tracing the body. search() declares it at :118, and the only two calls the method makes are:
candidates = self._fetch_candidates(...) # keyword_weight not passed
return self._process_candidates(...) # keyword_weight not passed
The identifier is never referenced again anywhere in search(). The only place weighted blending survives is _merge_results() (:688-730, the vector_weight=0.7 / keyword_weight=0.3 path), which search() never calls — it is reachable only from the legacy pre-unified-pipeline code.
A reader who sets keyword_weight=0.8 to bias toward keyword matching gets byte-identical results and no error, which is the worst failure mode for a tuning knob.
| <ParamField path="keyword_weight" type="float | None" default="None" toc={true}> | |
| <ParamField path="keyword_weight" type="float | None" default="None" toc={true}> | |
| Accepted for API compatibility and currently ignored. Scoring is always | |
| max-signal-wins with an agreement boost, so setting this has no effect on | |
| the results. | |
| </ParamField> |
| personality and context for asking the questions. | ||
| </ParamField> | ||
|
|
||
| <ParamField path="isolated" type="bool" default="False" toc={true}> |
There was a problem hiding this comment.
The normalization this PR describes stops at the new parameter. isolated correctly lands as type="bool" default="False", but the three siblings above it on this same page were left in the old style while the PR was already in the file.
core/contexts.py:407-413:
def set_gather_info(
self,
output_key: str | None = None,
completion_action: str | None = None,
prompt: str | None = None,
isolated: bool = False,
) -> "Step":
Against the current page:
| Param | Source | Page |
|---|---|---|
output_key (line 22) |
str | None = None |
type="str", no default |
completion_action (line 27) |
str | None = None |
type="str", no default |
prompt (line 35) |
str | None = None |
type="str", no default |
Per the PR description — "Normalize the Python reference: X | None types, default="None" on optional params" — all three should be type="str | None" default="None". As written the page reads as if they were required, which is the exact ambiguity the normalization exists to remove. completion_action's own body text already documents a None case ("None -- return to normal step mode after gathering"), so the page currently contradicts its own ParamField.
Suggested for line 22, with the same treatment for 27 and 35:
-<ParamField path="output_key" type="str" toc={true}>
+<ParamField path="output_key" type="str | None" default="None" toc={true}>
| Additional function names to make visible while asking this question. | ||
| </ParamField> | ||
|
|
||
| <ParamField path="isolated" type="bool | None" default="None" toc={true}> |
There was a problem hiding this comment.
Same normalization gap as set-gather-info.mdx. The new isolated field is correct (bool | None, default="None"); the two params immediately above it are not.
core/contexts.py:448-457:
def add_gather_question(
self,
key: str,
question: str,
type: str = "string",
confirm: bool = False,
prompt: str | None = None,
functions: list[str] | None = None,
isolated: bool | None = None,
) -> "Step":
Page lines 39 and 43 declare prompt as type="str" and functions as type="list[str]", neither with a default. Both are optional with a None default in source, so both read as required.
-<ParamField path="prompt" type="str" toc={true}>
+<ParamField path="prompt" type="str | None" default="None" toc={true}>
Extra instruction text appended for this specific question.
</ParamField>
-<ParamField path="functions" type="list[str]" toc={true}>
+<ParamField path="functions" type="list[str] | None" default="None" toc={true}>
Additional function names to make visible while asking this question.
</ParamField>
| [request-options]: /docs/server-sdks/reference/python/rest/request-options | ||
|
|
||
| Most resources with a `list()` method also have `paginate()`, which returns an | ||
| iterator over every item across all pages. A few list endpoints, such as |
There was a problem hiding this comment.
Right conclusion, wrong cause — and it under-counts. "Return a single page only" attributes this to the endpoint, but it is an SDK-side gap: paginate() is defined on ReadResource, and these resources extend BaseResource directly, so the method simply isn't there.
rest/_base.py:
class BaseResource: # :326 — no paginate
class ReadResource(BaseResource, Generic[TList, TItem]): # :337
def list(...) # :345
def paginate(...) # :357
rest/namespaces/relay_rest_resources_generated.py:68:
class Addresses(BaseResource):
# Typed resource for /addresses (generated).
I enumerated every generated resource class. 25 inherit paginate(); 8 expose list() without it:
Addresses, Recordings, RegistryBrands, ShortCodes, GenericResources (client.fabric.resources), CxmlApplications (client.fabric.cxml_applications), VideoRoomRecordings (client.video.room_recordings), ConferenceLogs (client.logs.conferences).
No generated class defines its own paginate — grep -rn "def paginate" rest/namespaces/ returns 0 hits — so base-class inheritance is the whole story.
The practical difference: as written, a reader who hits AttributeError: 'ConferenceLogs' object has no attribute 'paginate' won't connect it to this sentence, because client.logs.conferences isn't one of the two examples. Listing the set makes it checkable.
| iterator over every item across all pages. A few list endpoints, such as | |
| Most resources with a `list()` method also have `paginate()`, which returns an | |
| iterator over every item across all pages. Eight don't: `client.addresses`, | |
| `client.recordings`, `client.short_codes`, `client.registry.brands`, | |
| `client.fabric.resources`, `client.fabric.cxml_applications`, | |
| `client.video.room_recordings`, and `client.logs.conferences` expose `list()` | |
| only. `list()` returns one raw page, the |
|
|
||
| ## **Returns** | ||
|
|
||
| `bool` -- `True` once the leg is captured, `False` for an invalid or expired handle. |
There was a problem hiding this comment.
True doesn't mean the leg was captured. _capture()'s return value is discarded — the only thing that can produce False here is read_handle() raising.
ai_chat/handoff.py:290-302:
async def escalate(self, handle: str) -> bool:
try:
conversation_id = self.gateway.read_handle(handle)
except Exception:
return False
await self._capture(conversation_id, "chat") # <- result not bound
logger.info("handoff_escalated", conversation_id=conversation_id)
return True
And _capture() returns False on three paths that all still yield escalate() == True (:229-253):
if self.capture_leg is None:
return False
try:
return bool(await asyncio.wait_for(..., timeout=self.capture_timeout))
except TimeoutError:
logger.warning("handoff_capture_timeout", ...,
note="starting the next medium without this leg's record")
except Exception as exc:
logger.error("handoff_capture_failed", ...)
return False
So a HandoffRouter built without capture_leg — which the index page documents as supported ("When omitted, no wait happens and the ordering guarantee isn't provided") — returns True from every escalate() call while capturing nothing at all. Reading True as "the leg is captured" is precisely the wrong inference for the ordering guarantee this method exists to provide.
| `bool` -- `True` once the leg is captured, `False` for an invalid or expired handle. | |
| `bool` -- `True` when the handle was valid and the capture attempt has finished, | |
| `False` for an invalid or expired handle. `True` doesn't confirm a record was | |
| written: if `capture_leg` is unset, times out after `capture_timeout`, or raises, | |
| the failure is logged and this still returns `True`. |
| </ParamField> | ||
|
|
||
| <ParamField path="model" type="SentenceTransformer | None" toc={true}> | ||
| <ParamField path="model" type="SentenceTransformer | None" default="None" toc={true}> |
There was a problem hiding this comment.
Missing min_chunk_size — added to IndexBuilder after this PR's sync point, so it's genuine post-3.3.0 drift rather than an oversight in the original pass. grep -rn "min_chunk_size" fern/products/server-sdks/ returns 0 hits.
search/index_builder.py:50, 68-74, 83:
min_chunk_size: int = 0,
...
min_chunk_size: For markdown strategy - minimum words before a
heading starts a new chunk. Sections shorter than this are
merged forward into the next one instead of being emitted
alone. Defaults to 0, which preserves the previous behaviour
of splitting at every heading.
...
self.min_chunk_size = min_chunk_size
It is both a constructor argument and an instance attribute, so it belongs in this Properties list alongside chunking_strategy. It's forwarded to the internal DocumentProcessor at :113 and written into the index metadata at :687, which means an index built with a non-default value carries it — worth being discoverable.
Suggested addition after the chunking_strategy property (lines 35-37):
<ParamField path="min_chunk_size" type="int" default="0" toc={true}>
For the `markdown` strategy, the minimum words a section needs before a
heading is allowed to start a new chunk. Shorter sections merge forward
into the next one instead of being emitted alone. `0` splits at every
heading.
</ParamField>
Also needs adding to document-processor/index.mdx (see that comment) and as --min-chunk-size on cli/sw-search.mdx.
| </ParamField> | ||
|
|
||
| <ParamField path="split_newlines" type="int | None" toc={true}> | ||
| <ParamField path="split_newlines" type="int | None" default="None" toc={true}> |
There was a problem hiding this comment.
Missing min_chunk_size, and chunk_size is now under-described. Both are post-3.3.0 SDK changes.
1. min_chunk_size is undocumented. search/document_processor.py:119, 141-145, 157:
min_chunk_size: int = 0,
...
min_chunk_size: For markdown strategy - minimum words before a
heading starts a new chunk. Sections shorter than this are
merged forward into the next one instead of being emitted alone.
...
self.min_chunk_size = min_chunk_size
The implementation, and why it exists (:470-476):
# Minimum size before a heading is allowed to start a new chunk. Without
# this the walker flushes at EVERY heading, so a document with dense
# headings yields chunks that are a title plus one sentence -- true to
# the structure but too thin to retrieve on, and they crowd out the
# substantial chunks around them. 0 keeps the original behaviour.
min_chars = max(0, self.min_chunk_size * 6)
2. Line 34 says chunk_size is "Word count per chunk when using the sliding strategy." That's now incomplete: it also drives the markdown strategy. The SDK's hand-maintained help text states it is "the split threshold, applied as chunk_size * 6 characters" for markdown. A reader tuning a markdown index from this page has no reason to touch chunk_size at all.
Suggested — amend lines 34-36 and add the new property:
<ParamField path="chunk_size" type="int" toc={true}>
Word count per chunk for the `sliding` strategy. The `markdown` strategy
also uses it as its split threshold, applied as `chunk_size * 6`
characters.
</ParamField>
<ParamField path="min_chunk_size" type="int" default="0" toc={true}>
For the `markdown` strategy, the minimum words a section needs before a
heading is allowed to start a new chunk. Shorter sections merge forward
into the next one rather than being emitted alone, which keeps a densely
headed document from producing chunks too thin to retrieve on. `0` splits
at every heading.
</ParamField>
Address the PR #667 review. Post-3.3.0 drift: add min_chunk_size to IndexBuilder, DocumentProcessor, and sw-search; note chunk_size as the markdown split threshold; replace the retracted "better quality" spacy claim with the SDK's measure-it wording. Accuracy: keyword_weight is accepted and ignored; escalate() returning True doesn't confirm a captured record; paginate lists the eight list()-only resources. Normalize optional params on the gather, execute, and to_swaig pages. Point sdk-source-sync.json at v3.4.1 (the previous commit was a squash-merged branch head that no longer exists upstream). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Description
Sync the Python Server SDK reference to signalwire-python 3.3.0.
MessagesandProjectsREST resources,RequestOptions,paginate(), andSignalWireRestTransportErrorclient.compat) and redirect its URLs to the REST overviewCallconvenience methods (play_*,detect_*,prompt_*,wait_for_*)HandoffRouter, client capabilities helpers, and post-prompt normalizationAgentBase.mount,add_per_call_config,handle_request,set_multilingual,on_call_end,swaig_secretset_history, gatherisolated, and newvalidate()checksFunctionResultresponse as a prompt to the model; addset_tool_responseand hold routingX | Nonetypes,default="None"on optional params,## **Parameters**headingsFollow-up
stepandtimeout_stepon theholdaction, which the SDK emits: SWML spec: hold action is missingstepandtimeout_step#666Type of Change
Related Issues
Testing
yarn fern-md-checkpassesyarn fern-checkpasses apart from the unauthenticated redirects checkChecklist
Additional Notes