Step-by-step guide to building a new first-party connector for Springtale.
| Native | WASM | |
|---|---|---|
| When | First-party, audited by the team | Community-authored, untrusted |
| Language | Rust | TypeScript SDK → wasm32-wasip2, or Rust |
| Isolation | In-process, capability-checked | Wasmtime sandbox (10M fuel, 64MB mem, 30s timeout) |
| Trust | High | Low |
This guide covers native Rust connectors. WASM connector authoring will be covered once the TypeScript SDK lands.
connectors/connector-{name}/
├── Cargo.toml
└── src/
├── lib.rs # pub mod + re-exports ONLY
├── config.rs # Config struct (Deserialize only, Secret<String> for credentials)
├── connector.rs # Connector trait implementation
├── error.rs # Typed error enum (thiserror)
├── auth/ # Auth flows (OAuth, API key, bearer)
│ └── mod.rs
├── client/ # Typed HTTP client (all network calls here)
│ ├── mod.rs
│ └── api.rs
├── triggers/ # One module per trigger type
│ └── mod.rs
└── actions/ # One module per action type
└── mod.rs
Fig. 1. Standard connector directory layout.
[package]
name = "connector-{name}"
version = "0.1.0"
edition = "2024"
[dependencies]
springtale-connector = { workspace = true }
springtale-crypto = { workspace = true }
reqwest = { workspace = true }
secrecy = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
async-trait = { workspace = true }
[dev-dependencies]
tokio = { workspace = true, features = ["test-util"] }All version pins come from the workspace root Cargo.toml. Never specify versions in connector crates.
The Connector trait lives in springtale-connector:
#[async_trait]
pub trait Connector: Send + Sync + 'static {
fn triggers(&self) -> &[TriggerDecl];
fn actions(&self) -> &[ActionDecl];
async fn execute(&self, action: &str, input: serde_json::Value)
-> Result<ActionResult, ConnectorError>;
async fn on_event(&self, trigger: &str, handler: EventHandler)
-> Result<(), ConnectorError>;
fn manifest(&self) -> &ConnectorManifest;
}Implement this in connector.rs. The struct should hold the config, an HTTP client, and any runtime state (auth tokens, caches).
Config structs derive Deserialize only — never Serialize (this prevents secrets from appearing in serialized output).
use secrecy::SecretBox;
use serde::Deserialize;
#[derive(Deserialize)]
pub struct MyConfig {
pub api_key: SecretBox<String>, // Secret<String> — cannot be logged or serialized
pub api_base: String, // Non-secret fields are plain types
pub timeout_secs: u64,
}Every credential, token, and password is Secret<String>. Every .expose_secret() call is annotated with // SECURITY: expose needed for X.
All network calls go through a typed client in client/. No raw reqwest calls outside this module.
pub struct MyClient {
http: reqwest::Client,
api_base: String,
api_key: SecretBox<String>,
}
impl MyClient {
pub async fn search(&self, query: &str) -> Result<SearchResult, MyError> {
let resp = self.http
.get(format!("{}/search", self.api_base))
.header("Authorization", format!(
"Bearer {}",
self.api_key.expose_secret() // SECURITY: expose for API auth header
))
.query(&[("q", query)])
.send()
.await?;
// ...
}
}The reqwest client must use rustls-tls (this is enforced at the workspace level — native-tls is banned).
Triggers declare what events your connector emits. Each trigger has a name and a typed payload schema.
For webhook-based triggers, implement signature verification in your connector (HMAC-SHA256, RSA, etc.). The daemon's /webhook/{connector}/{trigger} endpoint forwards the raw request body to your handler.
For polling/streaming triggers (like Bluesky's Jetstream), implement the subscription loop and call the EventHandler when events arrive.
Actions declare what your connector can do. Each action has a name, typed input, and typed output.
Declare the capabilities your actions require in the manifest. The runtime checks capabilities before every execute() call — your action will never run without the required permissions.
Every connector ships with a TOML manifest:
[connector]
name = "connector-myservice"
version = "0.1.0"
author = "Your Name"
description = "MyService integration for Springtale"
[[capabilities]]
type = "NetworkOutbound"
host = "api.myservice.com"
[[triggers]]
name = "item_created"
description = "Fires when a new item is created"
[[actions]]
name = "create_item"
description = "Create a new item"
[data_disclosure]
description = "Sends item title and body to api.myservice.com"Place in the same file as the implementation:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_action_validates_input() {
// ...
}
#[tokio::test]
async fn test_execute_returns_result() {
// ...
}
}Don't mock reqwest directly. Create a trait for your client and provide a mock implementation for tests. This keeps tests fast and deterministic.
Pattern: test_{function}_{scenario}_{expected}
#[test]
fn test_search_empty_query_returns_error() { ... }
#[test]
fn test_search_valid_query_returns_results() { ... }Manifests can be signed with Ed25519 for verification:
# Generate a signing key (or use the vault keypair)
# Sign the manifest
# The signature is verified at install time and on every loadAdd the connector to the workspace Cargo.toml:
[workspace]
members = [
# ...existing members...
"connectors/connector-myservice",
]- [1] Connector trait:
crates/springtale-connector/src/connector/trait_.rs - [2] Capability types:
crates/springtale-connector/src/manifest/types.rs - [3] Connector guidelines:
.claude/rules/connector-guidelines.md - [4] Testing conventions:
.claude/rules/testing.md - [5] Security rules:
.claude/rules/security.md - [6] Existing connectors for reference:
connectors/connector-kick/,connectors/connector-github/