Skip to content

Update Python Server SDK tech docs - #667

Open
hey-august wants to merge 21 commits into
mainfrom
august-20260904-update-python
Open

Update Python Server SDK tech docs#667
hey-august wants to merge 21 commits into
mainfrom
august-20260904-update-python

Conversation

@hey-august

Copy link
Copy Markdown
Collaborator

Description

Sync the Python Server SDK reference to signalwire-python 3.3.0.

  • Add Messages and Projects REST resources, RequestOptions, paginate(), and SignalWireRestTransportError
  • Remove the Twilio-compatibility REST namespace (client.compat) and redirect its URLs to the REST overview
  • Add typed Relay Call convenience methods (play_*, detect_*, prompt_*, wait_for_*)
  • Add HandoffRouter, client capabilities helpers, and post-prompt normalization
  • Add AgentBase.mount, add_per_call_config, handle_request, set_multilingual, on_call_end, swaig_secret
  • Add ContextBuilder set_history, gather isolated, and new validate() checks
  • Reframe FunctionResult response as a prompt to the model; add set_tool_response and hold routing
  • Sync env vars, CLI flags, and helpers with 3.3.0
  • Normalize the Python reference: X | None types, default="None" on optional params, ## **Parameters** headings

Follow-up

Type of Change

  • Bug fix
  • New feature
  • Breaking change
  • Documentation update
  • Code cleanup / refactor

Related Issues

Testing

  • yarn fern-md-check passes
  • yarn fern-check passes apart from the unauthenticated redirects check
  • Every parameter, default, return type, and behavior claim verified against SDK source at 7f11951

Checklist

  • I have read the CONTRIBUTING guidelines
  • My code follows the project's style guidelines
  • I have added tests for my changes (if applicable)
  • I have updated documentation (if applicable)
  • All existing tests pass

Additional Notes

hey-august and others added 19 commits September 6, 2026 03:13
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>
@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

@hey-august hey-august changed the title Update Python SDK tech docs Update Python Server SDK tech docs Sep 8, 2026

@Devon-White Devon-White left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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 defaults nonce_ttl=3600 / max_messages_per_call=200 / capture_timeout=8.0 (ai_chat/handoff.py:92-94), route status codes, NonceEntry exported from signalwire.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).
  • FunctionResulthold() incl. the isinstance(prompt, int) back-compat branch and the bare-integer-unless-routing emission (core/function_result.py:518-594), set_tool_response, rpc_ai_message ValueError, rpc_ai_global_data.
  • core.capabilities and core.post_prompt — including the voice/chat divergence table, which matches the module docstring verbatim.
  • ContextBuilder.validate() — the new initial_step, reserved-name, and dangling-set_functions checks, 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_TOKEN has 0 hits, SIGNALWIRE_API_TOKEN has 18, so the builtin-skills.mdx fix is right.
  • RESTRequestOptions fields/defaults, the POST/PATCH retry asymmetry (rest/_request_options.py:138-160), PaginatedIterator's empty-page and cycle-guard behavior, Messages/Projects, client.compat genuinely gone, SignalWireClientRestClient (rest/__init__.py), area_codeareacode (confirmed against specs/signalwire-rest/relay-rest/phone-numbers/main.tsp:111), and client.calling really 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 on search/helpers.mdx.
  • --min-chunk-size is missing entirely (cli/build_search.py:256), and --chunk-size gained 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 is min_chars = max(0, self.min_chunk_size * 6) at search/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",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This commit does not exist in signalwire/signalwire-python.

$ git cat-file -t 7f11951c2623c5ec11178f553cbcb663dbe4bcb3
fatal: git cat-file: could not get object info

It 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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

Suggested change
- `"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}>

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

Suggested change
<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}>

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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}>

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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 paginategrep -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.

Suggested change
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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

Suggested change
`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}>

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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}>

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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>

hey-august and others added 2 commits September 9, 2026 14:48
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants