This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
This repo is public. Code, comments, commit messages, and PR descriptions are world-readable. Do not include internal-only details — only reference what is already public via the external REST APIs and the SDK's own public surface.
- OK to mention: public REST endpoints, request/response shapes documented at quicknode.com/docs, the SDK's own exported types/methods, public package names (
@quicknode/sdk,quicknode-sdk,quicknode_sdk). - Do not mention: internal service names, internal infra/hostnames, private repos or paths, Linear/Jira ticket IDs, Slack channels, employee names, internal incident details, internal API quirks not reflected in the public docs, or "why we chose X" reasoning that exposes internal strategy.
- If a fix is driven by internal context (incident, ticket, private discussion), describe the observable behavior that's being fixed — not the internal trigger. The commit message and PR description should read as if written by an external contributor.
- When in doubt, ask before publishing.
cargo check # Type check all crates
cargo build -p quicknode-sdk # Build core crate
cargo test -p quicknode-sdk --lib # Run tests (excludes examples)
cargo run --example admin -p quicknode-sdk # Run example (requires QN_API_KEY env var)just python-setup # Create .venv and uv sync (one-time)
just python-build # Compile bindings + generate stubs
cp python/quicknode_sdk/init_manual_override.pyi python/quicknode_sdk/__init__.pyi # Manually override __init__ so we can overwrite the commandsBoth recipes are shell-agnostic — they invoke maturin via uvx, so no venv activation is required and they work in bash, zsh, or fish without per-shell setup.
just node-build # npm install + build + testjust ruby-build # cargo build + copy .bundle artifactThe build compiles crates/ruby and copies the resulting libquicknode_sdk.dylib to ruby/lib/quicknode_sdk/quicknode_sdk.bundle (macOS) or equivalent .so on Linux. The native lib lives under lib/quicknode_sdk/ so require_relative "quicknode_sdk/quicknode_sdk" picks the platform extension automatically.
When verifying changes, use these commands based on what was modified:
- Rust only —
cargo check && just lint - Python crate/bindings —
just python-setup(first time only), thenjust python-build - Node/npm —
just node-build - Ruby —
just ruby-build - Full verification —
cargo check && just lint && just python-build && just node-build && just ruby-build && just test
Note: Do not use
cargo builddirectly — Python bindings are compiled via maturin (just python-build).
if you can't run a just command, see what it's executing and run it manually
This is a polyglot SDK: one Rust core library with Python, Node.js, and Ruby bindings generated from the same types
crates/core— Pure Rust business logic (HTTP client, request/response types, errors)crates/python— PyO3 wrapper crate, compiles toquicknode_sdk._corePython extensioncrates/node— napi-rs wrapper crate, compiles to native.nodemodulecrates/ruby— Magnus wrapper crate, compiles to native.bundle/.somodulecrates/python-stubs— Generates.pyitype stub filespython/quicknode_sdk/— Python package directory (distributed via maturin)npm/— Node.js package directoryruby/— Ruby package directory (lib/quicknode_sdk.rbentry point,examples/)
QuicknodeSdkis the root entry point holding sub-clients (e.g.,admin: AdminApiClient). All clients share aSdkConfig(Arc<SdkConfigInner>)wrapping onereqwestHTTP client and the API key.- There are clients per Quicknode product, with functions mapping to API calls
- Request params and Responses should be fully typed structs
Each sub-client module defines its own resolved config struct that holds the parsed, validated state derived from its public config type:
// In crates/core/src/<client>/mod.rs
pub(crate) struct Resolved<Name>Config {
pub(crate) base_url: reqwest::Url,
// other resolved fields...
}
impl Resolved<Name>Config {
pub(crate) fn from_config(config: Option<&<Name>Config>) -> Result<Self, SdkError> {
// parse and validate here
}
}SdkConfigInner holds one field per sub-client (e.g., admin: admin::ResolvedAdminConfig), and SdkConfig exposes a matching accessor (e.g., fn admin(&self) -> &admin::ResolvedAdminConfig). Call sites use self.config.admin().base_url instead of a flat admin_base_url field. Resolved config structs should be cheaply cloneable — prefer types like reqwest::Url (which implements Clone) and avoid heap allocations that would make cloning expensive; SdkConfig itself is a cheap clone via Arc<SdkConfigInner>.
- Any update to types in the core crate need to be checked for updates in the language crates (python, node, ruby)
Data types are defined once in crates/core/src/ with feature-gated attribute macros:
#[cfg_attr(feature = "python", gen_stub_pyclass)]
#[cfg_attr(feature = "python", pyclass(get_all, set_all))]
#[cfg_attr(feature = "node", napi(object))]
#[cfg_attr(feature = "rust", derive(Builder))]
pub struct SomeRequest { ... }pythonfeature — PyO3 class macros and stub generation viapyo3-stub-gennodefeature — napi-rs object macros and auto-generated TypeScript types innpm/index.d.tsrubyfeature — enablesmagnusdependency (optional); wrapping is done in the Ruby crate rather than via macros on core typesrustfeature —bonbuilder pattern for ergonomic Rust usage
SdkError (crates/core/src/errors.rs) uses thiserror with five variants:
Http— wrapsreqwest::Error(further classified viaSdkError::http_kind()→HttpKind::{Timeout, Connect, Other})Api— non-2xx response with status code and raw bodyDecode— JSON parse failure with raw body for debuggingUrlParse— invalid URL (wrapsurl::ParseError)Config— invalid configuration (string message)
Each binding exposes a typed exception hierarchy rooted at a shared base class so callers can rescue / catch / except by category. The mapping is:
SdkError variant |
Python / Ruby class | Node class | Base |
|---|---|---|---|
Config, UrlParse |
ConfigError |
ConfigError |
QuicknodeError |
Http + HttpKind::Timeout |
TimeoutError |
TimeoutError |
HttpError |
Http + HttpKind::Connect |
ConnectionError |
ConnectionError |
HttpError |
Http + HttpKind::Other |
HttpError |
HttpError |
QuicknodeError |
Api { status, body } |
ApiError (with .status, .body) |
ApiError (with .status, .body) |
QuicknodeError |
Decode { body, .. } |
DecodeError (with .body) |
DecodeError (with .body) |
QuicknodeError |
Each binding owns its mapping in a dedicated errors.rs file:
- Python —
crates/python/src/errors.rsusescreate_exception!macros;map_sdk_errsets.status/.bodyattributes viasetattron the exception instance. Exceptions are registered on the module inadd_to_module. - Node —
crates/node/src/errors.rsencodes the variant, status, and body into a tagged message ([<kind>|<status>|<body_len>]<msg>\x1f<body>) because napi-rs only supports plainnapi::Error. The JS wrappernpm/errors.js(fromNapiError+wrapClientProxy) parses the prefix and rethrows as the typed subclass. All client methods must be wrapped viawrapClientso sync throws and rejected promises are both re-tagged. - Ruby —
crates/ruby/src/errors.rsusesmodule.define_errorto build the class hierarchy underQuicknodeSdk::Error;map_errinstantiates the class and sets@status/@bodyivars (exposed viaattr_reader-style methods). Classes are captured once in aOnceLock<Opaque<ExceptionClass>>.
When adding a new SdkError variant:
- Add the variant to
crates/core/src/errors.rsand updatehttp_kind()if it's transport-level. - Update the
matchin each binding'smap_*_errfunction — the compiler will flag missing arms in Python and Ruby (Node's match is also exhaustive on the kind string). - If the new variant should surface as a new exception class, add it to all three bindings +
npm/errors.js+ the exports inpython/quicknode_sdk/__init__.py+npm/sdk.d.ts+npm/sdk.mjs. - Update examples in all four languages to demonstrate the new class if user-facing.
crates/python/src/lib.rs wraps core async methods using pyo3_async_runtimes::tokio::future_into_py. The Python API accepts individual keyword arguments instead of structs.
crates/node/src/lib.rs uses #[napi(constructor)] and #[napi(getter)] macros. napi handles async conversion automatically.
crates/ruby/src/lib.rs uses the magnus crate. All async SDK calls are wrapped via a single shared tokio::runtime (static OnceLock) using .block_on() to produce a synchronous Ruby API. Methods returning data return native Ruby Hash/Array (via serde_magnus); a per-client Ruby delegator in ruby/lib/quicknode_sdk/clients/ wraps responses in QuicknodeSdk::IndifferentHash (a Hash subclass with Hashie::Extensions::IndifferentAccess) so callers can use either symbol or string keys. All parameters are passed as a single Ruby Hash with symbol keys (e.g. get_endpoints(limit: 20)). Required keys throw ArgumentError if missing; unknown keys also throw ArgumentError (validated via validate_keys). The magnus arity limit of 15 is why this pattern is used uniformly — all methods are registered with arity 1 (or 0 for zero-param methods). The native binding classes are registered under QuicknodeSdk::Native::* (via #[magnus::init(name = "quicknode_sdk")]); user-facing QuicknodeSdk::SDK/Admin/Streams/Webhooks/KvStore are pure-Ruby wrapper classes defined in ruby/lib/quicknode_sdk/.
When adding a new Ruby method:
- Accept
opts: RHashas the single parameter - Call
validate_keys(&opts, &["key1", "key2", ...])?;as the first line - Use
hash_require_string/i64/i32/bool/vec_stringfor required fields andhash_get_*for optional fields - Register with
method!(ClassName::method_name, 1)in theinitfunction on theQuicknodeSdk::Native::*class (the user-facing Ruby wrapper inruby/lib/quicknode_sdk/clients/picks up new methods automatically viamethod_missing) - For methods returning data, the return type is
Result<magnus::Value, Error>and the call ends with.and_then(to_ruby). The Ruby delegator wraps the result inQuicknodeSdk::IndifferentHashautomatically — no per-method code is needed on the Ruby side. - Add a corresponding RBS signature to
ruby/sig/quicknode_sdk.rbsunder the matching client class. Method name must match step 4; keyword args must match thevalidate_keyslist from step 2 with types derived from thehash_require_*/hash_get_*accessors (hash_require_string→String,hash_get_string→ optional?key: String,hash_*_i32/i64→Integer,hash_*_bool→bool,hash_*_vec_string→Array[String],hash_get_map_string_string→Hash[String, String]). Useuntypedas the return type for methods that returnResult<magnus::Value, Error>(response is wrapped as aQuicknodeSdk::IndifferentHashat the Ruby boundary — access with[]ordig, not dot accessors) andvoidfor methods returningResult<(), Error>. If a new exception class is added, also add it to the error hierarchy section at the top of the same file.
Core clients are tested using mocked API calls with wiremock. All functions making external http calls should be tested this way and test the happy path, errors, with params, and with bad params. Keep testing focused and flexible, avoid overtesting
- When adding a new public type to
crates/core, export it across all four layers: Rust re-exports inlib.rs, Python__init__.py+init_manual_override.pyi, TypeScriptsdk.d.ts, and the Ruby binding incrates/ruby/src/lib.rs - Discriminated unions (Rust enum-with-data) cannot be annotated with
#[pyclass]or#[napi(object)]. Pattern: keep the enum pure-Rust incrates/corewith#[serde(tag = "...", content = "...")], then in each binding crate provide a typed surface that converts to/from the enum at the FFI boundary. Any core struct that flattens such an enum (e.g. via#[serde(flatten)]) also loses its#[pyclass]/#[napi(object)]and needs a wrapper in each binding. Reference implementations:crates/core/src/streams/stream.rs::DestinationAttributes— stream destinations, wrapped bycrates/{python,node}/src/streams_destination.rs.crates/core/src/webhooks/webhook.rs::TemplateArgs— webhook filter templates, wrapped bycrates/{python,node}/src/webhooks_template.rs. Ruby accepts the wire JSON directly ({"templateId":..., "templateArgs":...}) and relies on serde to deserialize into the enum.
python/quicknode_sdk/__init__.pyis manually maintained — it is NOT auto-generated. Every new public struct/type must be added to both thefrom quicknode_sdk._core import (...)block and the__all__list in this file- When adding a new type with
#[cfg_attr(feature = "node", napi(object))], also add it to the namedexport type { ... }block innpm/sdk.d.ts— this is the user-facing type file and is not auto-updated by napi-rs - When adding a new
#[napi(string_enum)]Rust enum, it generates a TypeScriptconst enuminnpm/index.d.ts. Innpm/sdk.d.ts, these must be re-exported using a regularexport { ... }(notexport type { ... }), otherwise TypeScript consumers cannot use them as values (e.g.,StreamDataset.Block) - When updating
sdk.jswrapper methods, verify the argument types match the underlying napi-rs constructor/method signature (object vs primitive) - When adding a new export to
sdk.js, also add it to the named exports innpm/sdk.mjs— ESM named exports cannot be spread dynamically and must be listed explicitly python/quicknode_sdk/__init__.pyiis overwritten byjust python-build— editinit_manual_override.pyiinsteadruby/sig/quicknode_sdk.rbsis manually maintained — it is NOT auto-generated. It provides RBS type signatures so editor LSPs (VSCode Ruby LSP, Solargraph, RubyMine, Steep) autocomplete method names and keyword argument keys forQuicknodeSdk::Admin/Streams/Webhooks/KvStore/DestinationAttributesand the exception classes. Every change to method registration incrates/ruby/src/lib.rs(new method, renamed key, new arg, removed arg, type change) must be mirrored here in the same PR. Responses are typed asuntypedbecause they're wrapped inQuicknodeSdk::IndifferentHashat the Ruby boundary — that's intentional, do not try to type response shapes.- Always update examples alongside the code changes
- Never derive
Debugon types containing sensitive values (API keys, tokens) without redaction — usesecrecy::SecretStringin internal structs, or a manualDebugimpl that prints[redacted] - Configurable URL overrides must be validated: normalize trailing slash before calling
.join()
- Library constructors should return
Result, not panic — use.unwrap()or.expect()only in examples and tests, never in library code - Do not silence
clippy::panic(orclippy::unreachable) with#[allow(...)]unless explicitly directed. If a test needs to fail on an unexpected enum variant, preferassert!(matches!(...))orlet ... else { unreachable!() }over amatcharm that callspanic!. - Validate numeric config values before casting between signed/unsigned types (e.g., check
>= 0beforei64 as u64) - Map
SdkErrorat the binding boundary only — keep core code returningResult<_, SdkError>, never a language-specific exception type. See the Error Handling section above for the typed exception hierarchy and how to add a new variant. - When a binding needs new error metadata (status, body, retry info, etc.), add it to the
SdkErrorvariant first, then surface it on the exception class in each binding (PyO3setattr, Rubyivar_set, Node tagged-message prefix). - Exception-raising tests belong in each language's example script (
crates/core/examples/admin_e2e.rs,python/examples/admin.py,npm/examples/admin.ts,ruby/examples/admin_e2e.rb) — assert on the typed class,status, andbodyso regressions in the mapping layer fail loudly.
- If the release is still an 0.1.z release, we don't need to worry about backwards compatability as this is a greenfield project
- Keep inline documentation comments updated with each change
- The repo has five READMEs, each with a defined scope:
- Root
README.md— project landing page: structure, install index, development, releasing. Do not add per-language API examples here. crates/core/README.md— Rust-only docs (renders on crates.io)python/README.md— Python-only docs (renders on PyPI)npm/README.md— Node.js-only docs (renders on npmjs)ruby/README.md— Ruby-only docs (renders on RubyGems)
- Root
- Any user-facing change to a method, parameter, return type, error class, or environment variable must be reflected in all four per-language READMEs in the same PR. This matches the polyglot consistency rule for
__init__.py,sdk.d.ts, andquicknode_sdk.rbsdocumented in §SDK-Specific Guidelines → Polyglot consistency. - The Configuration env-var table and the Error Handling class table are duplicated verbatim across all four per-language READMEs. When one changes, update all four — keep them byte-identical.
- Per-language READMEs are wired into package metadata (
crates/core/Cargo.tomlreadme,pyproject.tomlreadme,npm/package.jsonfiles,ruby/quicknode_sdk.gemspecs.files). When adding a new language or moving a README, update the corresponding manifest.
Precompiled binaries ship for these targets, controlled by .github/workflows/release.yml and Cross.toml:
- Linux glibc (Python, Node, Ruby) —
x86_64/aarch64, pinned to glibc 2.17 viacross+zig cc(manylinux2014 baseline). Do not bump this floor without updating the per-language READMEs and confirming we're prepared to drop RHEL 7 / Ubuntu 14.04-era distros. - Linux musl (Python, Node only) —
x86_64/aarch64. Ruby is excluded on purpose because the Rustcdylibis incompatible with musl (no dynamic linker); the source gem exists only as a Bundler fallback that raisesLoadError. - macOS —
aarch64-apple-darwin(Apple Silicon) only, built locally viajust macos-build-and-publishduring release. No Intel macOS, no Windows.
If you change the target matrix in release.yml, Cross.toml, npm/package.json's napi.targets, or Justfile's macos-build-and-publish, update the Platform Support section in all four per-language READMEs in the same PR.
- Use direct imports instead of glob imports
- Keep modules at the top of the files
- When doing anything out of the ordinary or breaking conventions or patterns, add a comment explaining the "why" behind it
- Do not reference Linear issue IDs (e.g.
DX-1234), PR numbers, ticket URLs, or other internal tracking identifiers in code comments. The "why" should be self-contained — describe the constraint or behaviour itself, not where to look it up. Internal context belongs in the commit message or PR description.