Conversation
razor-x
commented
Aug 13, 2026
Member
- feat: unify the SDK with the Seam SDKs for other languages
- refactor: keep the exception classes in the Seam namespace
- fix: namespace the generated nested resource classes
- refactor: share the Ruby SDK's merge-properties verbatim
- fix: lock dependencies against the minimum supported PHP
- feat: drop support for PHP 8.1
- docs: call endpoint methods with named arguments
- refactor: rename the multi workspace client to without workspace
- refactor: make the client the Guzzle client
- ci: construct the client the install check smoke tests
- feat: read the personal access token and workspace id from the environment
- fix: return a list of action attempts as a list
- fix: keep a retried request from duplicating a write
- fix: reject an option that a preconfigured client would silently discard
- feat: remove the lts_version method
- test: specify that a 503 on a read should be retried
- 4.0.0-beta.1
- Fix prune workflow job name
The Python, Ruby, and JavaScript SDKs share a runtime core, a common README skeleton, and a test suite aligned to the same baseline. This SDK had the codegen and release tooling but not the runtime surface. This brings it in line. Added: - Personal access token authentication, with the seam-workspace header, and token format validation that rejects client session tokens, JWTs, and publishable keys with a specific message. - from_api_key, from_personal_access_token, and from_client factories. - SeamMultiWorkspace for the endpoints that are not scoped to a workspace. - SeamWebhook, verifying incoming webhooks with svix. - SEAM_ENDPOINT support, plus the deprecated SEAM_API_URL and its warnings. - Retries, two by default with exponential backoff, via caseyamcl/guzzle_retry_middleware. A request that never reached the server is always retried; a status code is only retried for idempotent methods, since retrying a POST the server may already have processed could duplicate a write. The other Seam SDKs make the same trade. - HTTP layer configuration: guzzle_options, retries, and an injectable client. - A client level wait_for_action_attempt default, accepting a bool or a timeout and polling_interval. - A test suite covering auth, env, headers, errors, malformed responses, retries, pagination, serialization, action attempts, and webhooks, run against @seamapi/fake-seam-connect. - Psalm, wired into composer lint. Fixed: - Responses in the 3xx range were treated as successful. - The Seam error check accepted any body with a truthy error key. It now checks the content type and that error.type and error.message are strings, matching the other SDKs. - throw_http_errors let Guzzle throw before the SDK could map the error, making the whole error mapping unreachable. The option is gone. - Malformed JSON silently decoded to null and then failed on property access. - Non-Seam error responses raised an exception built from a fabricated request rather than the real one. - getRequestId returned an empty string rather than null when the header was absent, and the fallback error type was unknown rather than unknown_error. - HttpInvalidInputError never actually overrode the error code, and the action attempt errors wrote to an undeclared property. - Paginator::firstPage indexed its cache unconditionally, and the null cursor guard was unreachable. BREAKING CHANGE: The client is Seam\Seam; Seam\SeamClient remains as a deprecated alias. The constructor takes named options, so endpoint is no longer the second positional argument, and throw_http_errors is removed. Exceptions moved to the Seam\Exceptions namespace. poll_until_ready is removed in favor of wait_for_action_attempt, whose defaults change from 20s/0.4s to 10s/1s. $seam->client is a Seam\Http\SeamHttpClient rather than a Guzzle client. The $api_key property and the global LTS_VERSION constant are removed. Responses in the 3xx range are no longer treated as successful. Requests are now retried. Pagination metadata is a Seam\Pagination object. PHP 8.1 or later is required, and svix/svix is a new dependency. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fwgphpc9YhtxBbxuxppy8m
The core public API is small enough to read at a glance, so nesting part of it under Seam\Exceptions bought organization it does not need. Keeping the classes where 3.x had them also means existing catch blocks keep working. Sub-namespacing errors is the more common PHP convention, but the Python and JavaScript SDKs both export theirs at the package root, so this is closer to them as well. The new SeamException marker interface, InvalidOptionsError, and InvalidTokenError are all that changes for a caller upgrading from 3.x. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fwgphpc9YhtxBbxuxppy8m
A nested class was named after the root resource plus the property, but the base name was never replaced as the recursion descended, so every shape below the first level competed for one name and addClass silently kept whichever was generated first. device.properties.battery and device.properties.accessory_keypad.battery both resolved to DeviceBattery. The keypad won, so the device battery was generated with only level and $device->properties->battery->status did not exist. The same collapse hit the climate preset metadata, which took the shape of properties.ecobee_metadata and lost climate_ref, is_optimized and owner, and the phone_session credential and entrance metadata pairs. The flat map is now a recursive tree. A nested class is named after its property alone and declared in the namespace of the class that owns it, so the two batteries are Device\Properties\Battery and Device\Properties\AccessoryKeypad\Battery. Properties reference their nested classes relatively, letting PHP resolve them from the owning namespace. This also keeps Seam\Resources free of the hundreds of names that existed only to type a property. Where the old code silently overwrote, codegen now throws: on two siblings producing the same class name, on a name PHP reserves as a type, and on nesting deeper than 16 levels, which means a cyclic schema rather than a real shape. Merging a discriminated union moves to codegen/lib/merge-properties.ts, which unions by name recursively rather than taking the first occurrence, so a merged class keeps every variant's fields. A merged property keeps its description only when every variant that documents it agrees, because each variant documents the property for its own case and that text is not necessarily true of the class the variants collapse into. Deprecation is now deprecate-if-any, since first wins could undeprecate a field depending on blueprint ordering. This drops the description on is_device_error, which claimed the error is not a device error on every variant including the ones where it is, while is_bridge_error keeps the text all variants share. Resource classes are emitted as one braced namespace block per namespace in a single file per resource, which works because src/Resources is autoloaded by classmap rather than PSR-4. The resource and property docblock helpers are indented one level deeper to match. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fwgphpc9YhtxBbxuxppy8m
The merge module was written from the specification rather than copied, so it had drifted from the Ruby SDK in details that matter for keeping the two in step: the merged list was not sorted, the path in a list recursion omitted the [] segment, the format list in the disagreement error was sorted, and the error messages were worded differently. Take codegen/lib/merge-properties.ts from seamapi/ruby as it stands, so the two SDKs share one implementation and a future change to the semantics is a single diff to port rather than a reconciliation. Only the sort is observable here: it reorders the properties of the two merged resources, which reorders the nested classes emitted for them. The class and namespace sets of ActionAttempt and Event are unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fwgphpc9YhtxBbxuxppy8m
composer.json requires php ^8.1 but set no resolution platform, so the lock was
resolved against whatever PHP generated it. Generated on 8.4, that selected
Symfony 8.x, which requires php >=8.4.1, and composer install then refused the
lockfile on 8.1, 8.2 and 8.3:
Your lock file does not contain a compatible set of packages.
- symfony/console is locked to version v8.1.2 ...
- symfony/console v8.1.2 requires php >=8.4.1 -> your php version
(8.2.33) does not satisfy that requirement.
Pin config.platform.php to the oldest PHP this package supports so the lock
represents that platform rather than the machine that happened to write it.
Symfony drops to 6.4 LTS; Psalm and PHPUnit are unchanged.
The patch version is 8.1.31 because Psalm 6 requires ~8.1.31 on the 8.1 line.
The Install jobs passed throughout because they synthesize a composer.json and
never read this lockfile, which is why only Test and Lint caught it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fwgphpc9YhtxBbxuxppy8m
PHP 8.1 reached end of life in December 2025, so the oldest version this package supports is now 8.2, which has security support through 2026. Raise the floor in composer.json, move the resolution platform to 8.2.27, the patch Psalm 6 requires on the 8.2 line, and drop 8.1 from the CI matrices. Symfony moves up to 7.4 now that 8.2 is the target. BREAKING CHANGE: PHP 8.2 or later is required. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fwgphpc9YhtxBbxuxppy8m
PHP cannot declare a parameter as name-only the way Python's keyword-only marker and Ruby's required keyword arguments do, so the calling convention can only be documented rather than enforced. Parameter order comes from the API definition, so an endpoint that gains a required parameter can reorder the ones already there, and a positional call then binds a value to the wrong parameter with nothing to catch it. Every example now passes arguments by name, and the usage section says why. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fwgphpc9YhtxBbxuxppy8m
The JavaScript SDK renamed this client to SeamHttpWithoutWorkspace and left SeamHttpMultiWorkspace behind as a deprecated alias, since what the class actually does is reach the endpoints that take no workspace in scope rather than several workspaces at once. This SDK is introducing the class now, so it can start from the current name with no alias to carry. Seam\SeamMultiWorkspace becomes Seam\SeamWithoutWorkspace, mirroring Seam\Seam the way the JavaScript name mirrors SeamHttp. The auth helper and the README section follow, so multi workspace is gone as vocabulary. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fwgphpc9YhtxBbxuxppy8m
$seam->client was a wrapper that had to be unwrapped with get_client() to reach the Guzzle client underneath. It is now the Guzzle client itself, so anything Guzzle can do is reachable without a detour, and there is one client object in the public surface rather than two. Error mapping moves to Guzzle middleware, which fits better than the wrapper did: it sits outside the retry middleware, so it only sees the response a request finally settled on, and it holds the real request rather than a fabricated one when raising a transport error. Reading the response body moves to Seam\Http\Body, called by the generated route methods. The timeout drops from 60 to 30 seconds and becomes an option of its own rather than something to bury in guzzle_options, alongside retries. It covers connecting as well as reading. Seam::request() is gone; use $seam->client->request(). BREAKING CHANGE: $seam->client is now the Guzzle client, so $seam->client->get_client() no longer exists, and Seam\Http\SeamHttpClient is replaced by Seam\Http\ClientFactory. Seam::request() is removed. Requests now time out after 30 seconds rather than 60. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fwgphpc9YhtxBbxuxppy8m
The install check builds the package, requires it from a scratch project and constructs a client to prove the published archive autoloads. It still named Seam\SeamClient, which no longer exists, so the check failed on every PHP version once the alias went away. Construct Seam\Seam instead, by name, matching how the README documents calls. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fwgphpc9YhtxBbxuxppy8m
…nment SEAM_PERSONAL_ACCESS_TOKEN and SEAM_WORKSPACE_ID now fall back into place the way SEAM_API_KEY already did, so a client can be constructed with no arguments under either authentication method. Defining both credential variables at once is ambiguous and raises an InvalidOptionsError. SeamWithoutWorkspace reads SEAM_PERSONAL_ACCESS_TOKEN as well. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fwgphpc9YhtxBbxuxppy8m
Whether a method resolves its result as an action attempt was keyed on the return resource alone, so the list endpoint wrapped its entire action_attempts array in a single ActionAttempt and piped it through the resolver, which broke every call to it: an empty list decoded to null and a populated one polled an attempt with a null id. The array response now generates like any other list endpoint, which also restores the on_response hook the resolver branch skipped, so the paginator gets its pagination metadata. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fwgphpc9YhtxBbxuxppy8m
A timeout can fire while waiting on a response to a request the server received and is still processing, so retrying it can repeat a write, such as unlocking a door twice. Transport errors on a method that is not safe to repeat now go through a dedicated middleware that skips anything that looks like a timeout, while idempotent methods keep retrying every transport error. Connection resets keep retrying either way. The error mapping middleware also sat inside the redirect middleware, where raising on a 3xx made following redirects impossible even when asked for. It is unshifted to the outside of the stack, so a redirect is followed rather than raised and only an unfollowed one is an error. The handler stack a caller passes in is cloned rather than mutated, so building a second client from the same options no longer stacks the middleware twice and multiplies the retries. A bare handler, such as a MockHandler, is wrapped in a stack so the error mapping and retries apply to it instead of being silently dropped. And a response body that cannot seek, such as a streamed response, is read as is rather than failing on rewind. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fwgphpc9YhtxBbxuxppy8m
A credential or endpoint passed beside a client was dropped without a sound, since the client carries its own, so requests went out with whatever authorization the client held. The combination now raises an InvalidOptionsError naming the offending option, on both Seam and SeamWithoutWorkspace; wait_for_action_attempt stays allowed since it does not configure the client. Also swept up along the way: - The paginator replaced a caller's on_response callback with its own instead of chaining the two, so the caller's silently never fired. - WorkspacesProxy forwarded to the generated create positionally, the exact silent mis-binding the README warns about, and now uses named arguments. - The reserved name check in codegen only knew PHP's type names, so a property named list or default would have generated a class that cannot parse; it now covers the reserved keywords too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fwgphpc9YhtxBbxuxppy8m
It only echoed the LTS_VERSION constant, and its snake_case sat oddly beside createPaginator on the same class. Use Seam::LTS_VERSION. BREAKING CHANGE: lts_version() is removed from Seam and SeamWithoutWorkspace. Read the LTS_VERSION class constant instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fwgphpc9YhtxBbxuxppy8m
Repeating a read is safe, but every SDK call currently goes over POST, where a status based retry never is, so the SDK's own reads get none. The test asserts today's behavior and marks itself incomplete; once the SDK issues GET for the endpoints that support it, planned for a followup PR, the incomplete branch stops matching and the real assertions take over. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fwgphpc9YhtxBbxuxppy8m
feat: Implement Seam standard SDK interface
The route method partial wrote \\InvalidArgumentException, so the generated PHP carried a literal double backslash and failed to parse, breaking the Generate code job. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017FnqoQVLzdhRMBvFa6CoFG
Giving every parameter a null default disabled PHP's own arity check, so the generated methods hand rolled one. The check joined the required parameters with && and only fired when every one of them was missing, letting a call that omits some of them through to the API. PHP already rejects a missing argument, and no endpoint method declares an optional parameter ahead of a required one, so the defaults are dropped and the check goes away with them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017FnqoQVLzdhRMBvFa6CoFG
An endpoint whose schema requires one of its parameters marks none of them individually required, so PHP cannot enforce it through the method signature. Blueprint reports this as hasRequiredParameters, which the generated method now guards on. The condition is rendered from the parameter list in the layout rather than assembled in the context builder. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017FnqoQVLzdhRMBvFa6CoFG
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.