SDK Reference

Build connectors that fit your stack.

Ship Source and Sink connectors with the same delivery guarantees, schema handling, and replay as the built-in postgres and mssql connectors. One byte-frozen wire contract, a conformance suite, and catalog publishing.

Go Go SDK v0.1.0
$ go get zipline.run/sdk-go
Go 1.26+ Context-aware Protobuf wire
Rs Rust SDK preview
$ cargo add zipline-sdk
Edition 2021 Tokio async_trait
Overview

One frozen contract.

Custom connectors plug into the same runtime as the built-in postgres and mssql connectors. The SDK handles framing, batching, checkpoints, replay, retries, dead-lettering, and metrics - you implement Read (sources) or Write (sinks) against a small, byte-frozen interface.

  • The same content-addressed wire contract as the built-ins - no second-class treatment.
  • Schema is a first-class value: a source describes its relations once, the hot path emits schema-free Kind-tagged records, and column adds flow through as schema-change events.
  • At-least-once delivery plus idempotent sinks - replay is safe, so exactly-once is effective.
  • Run a connector in-process (runtime-embedded) or as an external process / sidecar over mTLS - same code, placement is a deployment choice.
Install

Add the SDK to your project.

Requires Go 1.26+. The SDK is pure value types and interfaces - its only non-stdlib dependency is google.golang.org/protobuf. Import it aliased as zip.

Shellterminal
# Add the SDK
go get zipline.run/sdk-go

# The reference connectors live in the same tree -
# copy connectors/postgres or connectors/mssql to start.
zipline connector list

Edition 2021, MSRV 1.75. Uses tokio for async; the SDK re-exports async_trait. The Rust SDK is in preview and mirrors the Go contract method-for-method.

Shellterminal
# Add the SDK (preview)
cargo add zipline-sdk

# Package + register a connector with the same CLI as Go:
zipline connector list
Core

The Connector interface.

Every connector implements Init and Stop. A source adds a read style (Read); a sink adds Write. Optional capabilities are detected from the interfaces you implement.

Gosdk-go/connector.go
package zip

// Every connector implements Init + Stop.
type Source interface {
    Init(ctx context.Context, cfg Config) error
    Stop(ctx context.Context) error
}

type Sink interface {
    Init(ctx context.Context, cfg Config) error
    Write(ctx context.Context, batch []Event) error // all-or-nothing, durable on return
    Stop(ctx context.Context) error
}

// A Source implements exactly one read style - PullReader is the common one.
type PullReader interface {
    Start(ctx context.Context, resume Position, p StartParams) error
    Read(ctx context.Context) (Batch, error)
}

// Optional capabilities - implement only what you need; the host detects them.
type Acker           interface { Ack(ctx context.Context, durable Position) error }
type Pauser          interface { Pause(context.Context) error; Resume(context.Context) error }
type SchemaDescriber interface { DescribeSchemas(context.Context) ([]Schema, error) }
type MetricsReporter interface { Metrics(context.Context) MetricSnapshot }

The Event type

Gosdk-go/event.go
type Event struct {
    Stream     string    // relation name, e.g. "public.orders"
    Key        []byte    // entity identity (partition / compaction)
    Op         Op        // Insert | Update | Delete | Snapshot | SchemaChange | Truncate
    Before     *Record   // nil for inserts
    After      *Record   // nil for deletes
    Position   Position  // opaque, per-stream monotonic, bytewise-comparable
    OccurredAt time.Time
    // … SchemaRef, CapturedAt, Transaction
}

type Batch struct {
    Events       []Event
    SafePosition Position // safe-resume low-water at batch end
}
Rustzipline-sdk/src/lib.rs
use async_trait::async_trait;

// Every connector implements init + stop.
#[async_trait]
pub trait Source: Send + Sync {
    async fn init(&mut self, cfg: Config) -> Result<()>;
    async fn stop(&mut self) -> Result<()>;
}

#[async_trait]
pub trait Sink: Send + Sync {
    async fn init(&mut self, cfg: Config) -> Result<()>;
    async fn write(&mut self, batch: Vec<Event>) -> Result<()>; // all-or-nothing
    async fn stop(&mut self) -> Result<()>;
}

// A Source implements exactly one read style.
#[async_trait]
pub trait PullReader: Source {
    async fn start(&mut self, resume: Position, p: StartParams) -> Result<()>;
    async fn read(&mut self) -> Result<Batch>;
}

// Optional capabilities - implement only what you need.
#[async_trait]
pub trait Acker: Source {
    async fn ack(&mut self, durable: Position) -> Result<()>;
}

The Event struct

Rustzipline-sdk/src/event.rs
pub struct Event {
    pub stream: String,           // relation name, e.g. "public.orders"
    pub key: Bytes,              // entity identity
    pub op: Op,                  // Insert | Update | Delete | Snapshot | …
    pub before: Option<Record>, // None for inserts
    pub after: Option<Record>,  // None for deletes
    pub position: Position,       // opaque, per-stream monotonic
    pub occurred_at: SystemTime,
}

pub struct Batch {
    pub events: Vec<Event>,
    pub safe_position: Position,
}
Core

Lifecycle.

The runtime drives a connector through a fixed lifecycle. Every method takes a context.Context - honor cancellation.

MethodApplies toContract
Init source · sink Decode the Config into your struct, open clients, validate. Called once before any data flows.
Start source Begin reading from the resume Position (or from now if empty). Stream or snapshot via StartParams.Mode.
Read source Return the next Batch; block until data is available or the context is cancelled. The host batches, checkpoints, and replays.
Write sink Persist the whole batch all-or-nothing; once Write returns, the batch is durable.
Ack source · opt The host calls Ack(durable) once a Position is safe downstream - advance your upstream cursor here, never before.
Pause / Resume source · opt Quiesce and resume the stream without tearing the connector down.
Stop source · sink Flush in-flight work and close clients. The process may exit after Stop returns.
Hazard
Cancellation is mandatory. Long-running Read/Write calls must abort cleanly when the context is cancelled. Connectors that ignore cancellation are killed forcibly and replayed from the last checkpoint.
Source

Building a source.

A source returns events as a Batch. The runtime batches further, checkpoints, and replays - you produce the next batch and advance only once the host acks a durable Position.

01
Block on Read
Don't busy-loop. Block until data is available or the context is cancelled - returning an empty Batch on an idle poll is fine.
02
Monotonic Positions
Encode Position so bytewise order equals event order. A Postgres LSN, a Kafka offset, a Redis stream id - wrap with zip.Uint64Position or zip.BytesPosition.
03
Honor Ack
Advance your upstream cursor only after the host acks the durable Position - never before. That's what makes replay safe.
Source · Example

A Redis Streams source.

A real connectors/redis package: it reads a Redis stream consumer group, returns one Batch per poll, and acks via XACK once the host confirms a durable Position.

Goconnectors/redis/source.go
package redis

import (
    "context"
    "time"

    "github.com/redis/go-redis/v9"
    zip "zipline.run/sdk-go"
)

type redisConfig struct {
    Addr     string `json:"addr"`
    Stream   string `json:"stream"`
    Group    string `json:"group"`
    Password string `json:"password" zip:"secret"`
}

type Source struct {
    cfg redisConfig
    rdb *redis.Client
}

// Compile-time proof we satisfy the contract + the capabilities the host wires.
var (
    _ zip.Source     = (*Source)(nil)
    _ zip.PullReader = (*Source)(nil)
    _ zip.Acker      = (*Source)(nil)
)

func (s *Source) Init(ctx context.Context, cfg zip.Config) error {
    if err := cfg.Map(&s.cfg); err != nil {
        return err
    }
    s.rdb = redis.NewClient(&redis.Options{Addr: s.cfg.Addr, Password: s.cfg.Password})
    return s.rdb.XGroupCreateMkStream(ctx, s.cfg.Stream, s.cfg.Group, "0").Err()
}

// Start is a no-op here: XReadGroup with ">" resumes from the group cursor.
func (s *Source) Start(_ context.Context, _ zip.Position, _ zip.StartParams) error { return nil }

func (s *Source) Read(ctx context.Context) (zip.Batch, error) {
    res, err := s.rdb.XReadGroup(ctx, &redis.XReadGroupArgs{
        Group:    s.cfg.Group,
        Consumer: "zipline",
        Streams:  []string{s.cfg.Stream, ">"},
        Count:    256,
        Block:    2 * time.Second,
    }).Result()
    if err == redis.Nil { return zip.Batch{}, nil } // idle poll: nothing new
    if err != nil { return zip.Batch{}, err }

    var b zip.Batch
    for _, msg := range res[0].Messages {
        rec := zip.NewRecord(msg.Values)              // map[string]any → *Record
        pos := zip.BytesPosition([]byte(msg.ID)) // stream ids sort bytewise
        b.Add(s.cfg.Stream, zip.OpInsert, rec, pos)
    }
    return b, nil
}

func (s *Source) Ack(ctx context.Context, durable zip.Position) error {
    return s.rdb.XAck(ctx, s.cfg.Stream, s.cfg.Group, string(durable)).Err()
}

func (s *Source) Stop(_ context.Context) error { return s.rdb.Close() }

func init() {
    zip.Register[redisConfig](zip.ConnectorDescriptor{
        Type:      "redis",
        Direction: zip.DirectionSource,
        Version:   "0.1.0",
    }, zip.Factory{NewSource: func() zip.Source { return &Source{} }})
}
Rustconnectors/redis/src/lib.rs
use zipline_sdk::{Acker, Batch, Config, Op, Position, PullReader, Record,
    Result, Source, StartParams, async_trait, register_source};
use redis::{aio::MultiplexedConnection, streams::StreamReadOptions, AsyncCommands};

#[derive(serde::Deserialize, Default)]
struct RedisConfig { addr: String, stream: String, group: String }

#[derive(Default)]
pub struct RedisSource { cfg: RedisConfig, conn: Option<MultiplexedConnection> }

#[async_trait]
impl Source for RedisSource {
    async fn init(&mut self, cfg: Config) -> Result<()> {
        self.cfg = cfg.parse()?;
        let client = redis::Client::open(self.cfg.addr.clone())?;
        self.conn = Some(client.get_multiplexed_async_connection().await?);
        Ok(())
    }
    async fn stop(&mut self) -> Result<()> { Ok(()) }
}

#[async_trait]
impl PullReader for RedisSource {
    // XReadGroup with ">" resumes from the group cursor.
    async fn start(&mut self, _resume: Position, _p: StartParams) -> Result<()> { Ok(()) }

    async fn read(&mut self) -> Result<Batch> {
        let conn = self.conn.as_mut().unwrap();
        let opts = StreamReadOptions::default()
            .group(&self.cfg.group, "zipline")
            .count(256)
            .block(2000);
        let reply: redis::streams::StreamReadReply =
            conn.xread_options(&[&self.cfg.stream], &[">"], &opts).await?;

        let mut b = Batch::default();
        for key in reply.keys {
            for id in key.ids {
                let rec = Record::from_redis(&id.map);       // fields → Record
                let pos = Position::from_bytes(id.id.as_bytes());
                b.add(&self.cfg.stream, Op::Insert, rec, pos);
            }
        }
        Ok(b)
    }
}

#[async_trait]
impl Acker for RedisSource {
    async fn ack(&mut self, durable: Position) -> Result<()> {
        let conn = self.conn.as_mut().unwrap();
        conn.xack(&self.cfg.stream, &self.cfg.group, &[durable.as_str()]).await?;
        Ok(())
    }
}

register_source!("redis", RedisSource);
Sink

Building a sink.

Sinks consume events in batches. The runtime is at-least-once; you provide idempotency. Key your writes on the record's primary key or the event Position.

01
Write is all-or-nothing
One transaction per batch - once Write returns, the batch is durable. Partial success means the whole batch replays.
02
Idempotent writes
Use upsert, ON CONFLICT, or a ReplacingMergeTree keyed on (Stream, Key). Implement IdempotentSink to advertise it.
03
Fail loud
Return an error to retry the batch with backoff; wrap zip.ErrPermanent to dead-letter poison data immediately and keep moving.
Sink · Example

A ClickHouse sink.

A real connectors/clickhouse package: it writes the whole batch in one INSERT (all-or-nothing) into a change-log table; a ReplacingMergeTree engine collapses any replays.

Goconnectors/clickhouse/sink.go
package clickhouse

import (
    "context"
    "encoding/json"

    "github.com/ClickHouse/clickhouse-go/v2"
    "github.com/ClickHouse/clickhouse-go/v2/lib/driver"
    zip "zipline.run/sdk-go"
)

type chConfig struct {
    Addr     string `json:"addr"`
    Database string `json:"database"`
    Table    string `json:"table"`
    Username string `json:"username"`
    Password string `json:"password" zip:"secret"`
}

type Sink struct {
    cfg  chConfig
    conn driver.Conn
}

var (
    _ zip.Sink           = (*Sink)(nil)
    _ zip.IdempotentSink = (*Sink)(nil)
)

func (s *Sink) Init(ctx context.Context, cfg zip.Config) error {
    if err := cfg.Map(&s.cfg); err != nil {
        return err
    }
    conn, err := clickhouse.Open(&clickhouse.Options{
        Addr: []string{s.cfg.Addr},
        Auth: clickhouse.Auth{Database: s.cfg.Database, Username: s.cfg.Username, Password: s.cfg.Password},
    })
    if err != nil { return err }
    s.conn = conn
    return conn.Ping(ctx)
}

// Write applies the whole batch in one INSERT - all-or-nothing, durable on return.
func (s *Sink) Write(ctx context.Context, batch []zip.Event) error {
    bw, err := s.conn.PrepareBatch(ctx,
        "INSERT INTO "+s.cfg.Table+" (stream, op, key, after, ts)")
    if err != nil { return err }

    for _, ev := range batch {
        after := []byte("{}")
        if ev.After != nil {
            after, _ = json.Marshal(ev.After.Map()) // Record → map[string]any
        }
        if err := bw.Append(ev.Stream, ev.Op.String(), string(ev.Key),
            string(after), ev.OccurredAt); err != nil {
            return err // abort the whole batch → full replay
        }
    }
    return bw.Send() // ReplacingMergeTree collapses replays
}

func (s *Sink) Idempotent() bool { return true }

func (s *Sink) Stop(_ context.Context) error { return s.conn.Close() }

func init() {
    zip.Register[chConfig](zip.ConnectorDescriptor{
        Type:      "clickhouse",
        Direction: zip.DirectionSink,
        Version:   "0.1.0",
    }, zip.Factory{NewSink: func() zip.Sink { return &Sink{} }})
}
Rustconnectors/clickhouse/src/lib.rs
use zipline_sdk::{Config, Event, IdempotentSink, Result, Sink, async_trait, register_sink};
use clickhouse::Client;
use std::time::UNIX_EPOCH;

#[derive(serde::Deserialize, Default)]
struct ChConfig { addr: String, database: String, table: String }

#[derive(clickhouse::Row, serde::Serialize)]
struct ChangeRow { stream: String, op: String, key: Vec<u8>, after: String, ts: u64 }

#[derive(Default)]
pub struct ClickHouseSink { cfg: ChConfig, client: Option<Client> }

#[async_trait]
impl Sink for ClickHouseSink {
    async fn init(&mut self, cfg: Config) -> Result<()> {
        self.cfg = cfg.parse()?;
        self.client = Some(Client::default()
            .with_url(&self.cfg.addr)
            .with_database(&self.cfg.database));
        Ok(())
    }

    // One INSERT for the whole batch - all-or-nothing.
    async fn write(&mut self, batch: Vec<Event>) -> Result<()> {
        let client = self.client.as_ref().unwrap();
        let mut insert = client.insert(&self.cfg.table)?;
        for ev in batch {
            let after = ev.after.map(|r| r.to_json()).unwrap_or_default();
            let ts = ev.occurred_at.duration_since(UNIX_EPOCH)
                .map(|d| d.as_millis() as u64).unwrap_or(0);
            insert.write(&ChangeRow {
                stream: ev.stream, op: ev.op.to_string(),
                key: ev.key.into(), after, ts,
            }).await?;
        }
        insert.end().await?; // ReplacingMergeTree collapses replays
        Ok(())
    }

    async fn stop(&mut self) -> Result<()> { Ok(()) }
}

impl IdempotentSink for ClickHouseSink {
    fn idempotent(&self) -> bool { true }
}

register_sink!("clickhouse", ClickHouseSink);
Production

Error handling.

Return the right error and the runtime does the right thing - retry, dead-letter, or stop. There's no taxonomy to learn: any error is retryable unless you mark it permanent.

ReturnRuntime behavior
error Retryable - the host retries the same read or batch with backoff and the connector keeps running. The default for transient failures.
zip.ErrPermanent Permanent - wrap it (or implement Permanent() bool) and the batch is dead-lettered immediately. No retry storm on poison data; processing continues.
ctx.Err() Cancellation - return promptly when the context is cancelled; the last checkpoint is preserved and the connector is torn down cleanly.
Goconnectors/clickhouse/sink.go
import (
    "context"
    "errors"
    "fmt"

    zip "zipline.run/sdk-go"
)

func (s *Sink) Write(ctx context.Context, batch []zip.Event) error {
    if err := s.apply(ctx, batch); err != nil {
        switch {
        case errors.Is(ctx.Err(), context.Canceled):
            return ctx.Err()                          // clean stop - checkpoint preserved
        case errors.Is(err, errBadSchema):
            return fmt.Errorf("clickhouse: %v: %w", err, zip.ErrPermanent) // dead-letter
        default:
            return err                                // transient - host retries with backoff
        }
    }
    return nil
}
Rustconnectors/clickhouse/src/lib.rs
use zipline_sdk::{Event, Result, SdkError};

async fn write(&mut self, batch: Vec<Event>) -> Result<()> {
    match self.apply(batch).await {
        Ok(()) => Ok(()),
        Err(e) if e.is_bad_schema() => Err(SdkError::permanent(e)), // dead-letter
        Err(e) => Err(SdkError::retryable(e)),            // host retries
    }
}
Reserve
Partial-batch outcomes. A sink can implement the reserve-only RowAwareSink to return a per-row []RowResult (OK / retryable / permanent) instead of failing the whole batch. It's defined in the frozen SDK but not yet wired in the pilot.
Production

Testing.

Two layers: drive your connector's public interface directly in a unit test, then run the SDK conformance suite to prove it behaves like a first-party connector.

Goconnectors/redis/source_test.go
func TestSource_ReadsEntries(t *testing.T) {
    src := &Source{}
    cfg := zip.ConfigFromJSON([]byte(`{"addr":"localhost:6379","stream":"orders","group":"zip"}`))

    if err := src.Init(t.Context(), cfg); err != nil {
        t.Fatal(err)
    }
    defer src.Stop(t.Context())

    if err := src.Start(t.Context(), nil, zip.StartParams{Mode: zip.ModeStream}); err != nil {
        t.Fatal(err)
    }
    batch, err := src.Read(t.Context())
    if err != nil { t.Fatal(err) }

    for _, ev := range batch.Events {
        amount, _ := ev.After.Int("amount")
        t.Logf("%s %s amount=%d", ev.Op, ev.Stream, amount)
    }
}
Rustconnectors/redis/tests/read.rs
#[tokio::test]
async fn reads_entries() {
    let mut src = RedisSource::default();
    let cfg = Config::from_json(r#"{"addr":"localhost:6379","stream":"orders","group":"zip"}"#);

    src.init(cfg).await.unwrap();
    src.start(Position::empty(), StartParams::stream()).await.unwrap();

    let batch = src.read().await.unwrap();
    for ev in batch.events {
        let amount = ev.after.unwrap().int("amount").unwrap();
        println!("{} {} amount={}", ev.op, ev.stream, amount);
    }
}

Conformance suite

The CLI runs the same behavioral suite the built-ins pass - in a subprocess, through your public Source/Sink interface.

Shellterminal
# Run the SDK conformance suite (Init → Read/Write → Stop)
zipline connector test redis --no-record

# Inspect the descriptor + reflected config schema
zipline connector describe redis
Tip
Drop --no-record and a green run records the Conformance result on the runtime, so the connector catalog surfaces that your type passed - the same gate the built-in postgres and mssql connectors clear.
Production

Publish to the catalog.

Package the in-binary descriptor into a portable .zcp file, then add it to the runtime's connector catalog. The config schema is reflected from your Go struct - never hand-written.

Shellterminal
# Package the in-binary descriptor into a portable .zcp
zipline connector build --type redis --out redis.zcp

# Add it to the runtime's connector catalog
zipline connector add redis.zcp

# Confirm it's registered + conformance-recorded
zipline connector list
zipline connector inspect redis

The descriptor is the manifest

There is no hand-written manifest. Your zip.Register call is the source of truth - the config schema (JSON Schema 2020-12) is reflected from the config struct, and optional catalog metadata rides along in zcp.Meta.

Goconnectors/redis/source.go
import (
    zip "zipline.run/sdk-go"
    "zipline.run/sdk-go/zcp"
)

// The descriptor - ConfigSchema is DERIVED at Register, never hand-written.
func init() {
    zip.Register[redisConfig](zip.ConnectorDescriptor{
        Type:         "redis",
        Direction:    zip.DirectionSource,
        Version:      "0.1.0",
        Capabilities: []string{"runtime-embeddable"}, // Ack/Pause/Metrics detected from the interfaces
    }, zip.Factory{NewSource: func() zip.Source { return &Source{} }})
}

// `connector build` does exactly this - Lookup the descriptor, then package it.
// Supply zcp.Meta to customise the catalog card:
d, _, _ := zip.Lookup("redis")
pkg, _ := zcp.Build(d, zcp.Meta{
    Presentation: &zcp.PresentationInput{
        DisplayName: "Redis Streams",
        Description: "Consume entries from a Redis stream consumer group.",
        License:     "Apache-2.0",
    },
    Compat: &zcp.CompatInput{WireProtocol: 1},
})
b, _ := zcp.Encode(pkg) // → bytes → write redis.zcp
Placement
Run it your way. The same connector runs in-process (runtime-embedded) for the lowest latency, or as an external process / sidecar over mTLS: zipline-connector run --connector redis --role source.

Ready to ship your own?

Star the SDK repo, file an issue, or browse the built-in postgres and mssql connectors for reference.