Behavioral divergences between the TypeScript port and the Python reference, and the verdict reached for each.
The cross-port gates police the surface (public signatures via DRIFT, the
exported symbol set via SURFACE, generated wire types via GEN-FRESH) and one
slice of emission (FunctionResult.toDict() via EMISSION). Historically they
did not cover a skill's SWAIG tool contract — the parameters /
required / enum each skill registers from getTools() — so two ports could
pass every gate and still hand the model different tools. Every divergence below
was found by hand because of that hole.
That hole is now closed by the SKILL-CONTRACT gate
(run-ci.sh → porting-sdk/scripts/diff_skill_contracts.py, dump
scripts/emit-skills.ts): it compares each covered skill's tool contract
against the Python reference and fails on any name/param/required/enum
divergence. Dynamic skills (mcp_gateway, claude_skills, datasphere_serverless,
native_vector_search) are excluded + logged (their contract depends on live
state). Tool/param DESCRIPTIONS and the implementation (offline vs API,
DataMap vs handler) are deliberately NOT compared — only the wire contract. The
gate found two divergences this session's manual audit had missed (web_search,
wikipedia_search). What it does NOT cover — config-validation timing, which
backend a handler calls — is still recorded here as KEEP notes.
This file is the human-readable ledger for those behavioral findings: each row is verified against source on both sides (file:line cited), with a verdict of FIXED, KEEP (TS is correct / intentional), UPSTREAM (the Python reference looks wrong; fix it there, not by downgrading TS), or OPEN (needs a dedicated pass). It is not machine-parsed — it is the record so nothing stays silent.
required note: Python's SWAIGFunction omits the required key entirely when
the list is empty (core/swaig_function.py:128). So "Python passes no
required arg" == "no required key on the wire" — a TS port that emits
required: [...] there is adding a key the reference never sends.
- TS emitted
required: ['query']; Python passes norequired(datasphere/skill.py:171). - Verdict: FIXED — dropped
requiredto match the reference's emission.args.queryis now optional, narrowed by the existing runtime guard.
- TS omitted
required; Python's DataMap tool marks itrequired: ['category'](api_ninjas_trivia/skill.py:179). - Verdict: FIXED — added
required: ['category']to match. The handler still defaults a missing value todefault_category.
- TS emitted
required: ['expression']; Python passes norequired(math/skill.py:33). - Verdict: FIXED — dropped
requiredto match the reference's emission.
- TS grew a free-form fallback (
play_background/stop_background+ arbitraryfile_url, plusdefault_file_url/allowed_domainsconfig) when nofileswere configured. Python REQUIRESfiles(play_background_file/skill.py:106raises) and only ever emits oneaction-enum DataMap tool. - Verdict: FIXED —
setup()now fails on emptyfiles; the free-form tools and config were removed; the singleaction-enum tool remains. - Residual (OPEN): Python builds this tool as a server-side DataMap
(
data_mapexpressions); TS uses a client-side handler. Same observable tool shape, different execution model. See "DataMap vs handler" below.
- The dynamic MCP tool: TS passes the MCP
inputSchema.requiredlist through to the SWAIG schema (mcp_gateway.ts:532). Python builds the identical params but never readsrequiredfrom the input schema, so it drops it (mcp_gateway/skill.py:269-274, norequired=arg). - Python honors
requiredin every other skill that has it (spiderskill.py:246, datasphere_serverless:185, joke:71, swml_transfer:186/195) — so the MCP omission is almost certainly an oversight, not a design choice. - Verdict: KEEP TS (it is the correct behavior — a model should know which
MCP args are required). UPSTREAM: file a fix for signalwire-python to wire
the MCP schema's
requiredthrough. TS is intentionally ahead of the reference here.
- Was: TS tool
tell_jokewith acategoryparam (enumgeneral / programming / dad); Python DataMap toolget_jokewith atypeparam (enumjokes / dadjokes, required) from the API-Ninjas webhook (joke/skill.py:68-77). - Principle applied (user, 2026-06-15): what matters is the skill's interface, not its internal implementation — the implementation may be language-specific. So the SWAIG contract was aligned to Python while the offline library was kept.
- Verdict: FIXED — TS now emits the Python interface: tool
get_joke(defaulttool_name), requiredtypeparam with enum['jokes', 'dadjokes']. The offline collection stays:dadjokes→ the curateddadcategory,jokes→ general/programming. No API key needed (intentional implementation difference; the contract a model sees is identical).
- Was a multi-divergence:
compute_routeshared the Python name but a different contract (TS address strings +modevs Python 4 coordinate floats);lookup_addressshared the name but used aqueryplace-search param vs Python'saddress+bias_lat/bias_lnggeocode; and TS carried two extra tools Python lacks (geocode_address,compute_route_by_coords, from124a615 align-batch3). - Verdict: FIXED (user: match Python exactly, drop the TS extras). The skill
now registers exactly the Python two:
lookup_address(paramsaddress,bias_lat,bias_lng; Geocoding API) andcompute_route(paramsorigin_lat/lng,dest_lat/lng; Routes API v2). Norequiredarrays (Python omits them). The richer TS-only tools (address directions, place search, standalone geocode) and thedefault_modeconfig were removed. Hints + prompt sections aligned to Python. Handlers keep defensive runtime presence/typeofchecks.
- TS uses OpenWeatherMap (
weather_api.tsheader); Python uses WeatherAPI.com (weather_api/skill.py:179). Sameget_weathertool shape (locationrequired), different backend — API keys are not interchangeable. Documented in the TS file header and the batch3 commit (124a615). - Verdict: KEEP — a deliberate provider choice, flagged for operators. Not a contract divergence (the tool shape matches); listed here only so the backend difference is on the record.
- Python validates
api_keyeagerly (raises in__init__/setup()); TS some skills check at handler time and return a "not configured" result. - Verdict: KEEP — observable failure mode is equivalent (the tool can't run without the key); the timing difference is idiomatic and low-stakes. Noted for completeness.
Several Python skills build their tool as a server-side DataMap (data_map
expressions evaluated by the SignalWire platform): joke, swml_transfer,
datasphere_serverless, api_ninjas_trivia, play_background_file. Some TS
ports mirror this with a real DataMap (datasphere_serverless, swml_transfer,
api_ninjas_trivia use data_map.webhooks), while others implement the same
observable tool with a client-side handler (play_background_file). Where
the emitted SWAIG parameters/required match, the model sees the same tool;
the execution model (server-evaluated vs SDK-evaluated) differs. This is tracked
as OPEN where it applies but is lower-stakes than a parameter-contract
divergence.