Inside the Odin implementation

Follow a product
into the CSV.

The collector queries IndieHackers' Algolia index and writes six columns to a local file. Explore how the native executable handles each page, rejects bad data and publishes an export.

A single page through the collectorSynthetic example

Ask the products index.

A POST request sends an empty search query and the chosen filters. This example uses the defaults. The first page is numbered zero.

Authentication travels in headers. Credentials stay on the machine running the collector.

POST/1/indexes/products/query
{
  "query": "",
  "hitsPerPage": 100,
  "page": 0,
  "filters": "_tags:founders-code-yes AND revenue >= 1"
}

Host {APP_ID}-dsn.algolia.net

Check before accepting.

The HTTP status must be 200. The decoder checks JSON grammar, page metadata and each product field.

A repeated nonempty ID stops the entire export. An error on a later page also discards earlier staged rows.

200Example search response
{
  "hits": [{
    "name": "Pocket Ledger",
    "tagline": "Invoices, without the clutter",
    "revenue": 2400,
    "objectID": "pocket/ledger",
    "websiteUrl": "https://example.com",
    "status": "active"
  }],
  "nbPages": 1,
  "page": 0
}

Quote the row. Keep the ID intact.

The CSV encoder quotes the comma in the tagline. The product URL encodes the slash in the ID as %2F.

Rows enter a private staging file. The final destination changes only after collection and publication checks pass.

CSVproducts.csv
NameTaglineMonthly Revenue ($)IndieHackers URLWebsiteStatus
Pocket LedgerInvoices, without the clutter2400https://www.indiehackers.com/product/pocket%2Fledgerhttps://example.comactive
Pocket Ledger,"Invoices, without the clutter",2400,https://www.indiehackers.com/product/pocket%2Fledger,https://example.com,active
Download this synthetic CSV

The example product is made up. Select a stage to inspect its data. This guide never contacts Algolia.

This is a rewrite of Rinkoff's Python collector. The original four flags and six CSV columns remain. Native HTTP, explicit allocation and a checked publication sequence replace the Python runtime and its packages.

One package.
Eight ownership boundaries.

The executable is a single Odin package. Select a file to see its role, what it owns and an excerpt of the current source.

confighttpjsonresponseexportcollector owns the loop. cancel spans it.

Start the process

main.odin

Parse the command, install signal handlers, load credentials and return an exit status.

Help exits before credentials are loaded. Invalid arguments return 2. Runtime failures return 1. A handled interrupt returns 130 or 143.

Owns
Options and the process allocator.
Procedures
parse_options, install_interrupts, load_credentials, collect
Read a source excerpt main.odin, lines 123 to 146
run :: proc() -> int {
	arena: mem.Arena
	defer mem.arena_destroy(&arena)
	context.allocator = mem.arena_allocator(&arena)
	context.temp_allocator = context.allocator

	options, help, ok := parse_options()
	if !ok {
		fmt.eprintln("Use --help for usage.")
		return 2
	}
	if help {
		fmt.print(HELP)
		return 0
	}
	if !install_interrupts() {
		return 1
	}
	credentials, configured := load_credentials()
	success := configured && collect(options, credentials)
	if !success && cancelled() {
		fmt.eprintln("[!] Interrupted.")
		return 128 + int(interruption())
	}
// Excerpt ends here.

152 lines in the file. SHA-256 bc4704fa07bcf01ba7f68ca658eb4496774a2003f6f10ed93b9d51efefa19513

Read local configuration

config.odin

Read a bounded .env file in the current directory and validate the Algolia credentials.

The environment takes precedence even when a variable is explicitly empty. The reader accepts one-line quotes and comments. It never evaluates shell expressions or searches parent directories.

Owns
The loaded credential strings.
Procedures
read_dotenv, dotenv_value, load_credentials
Read a source excerpt config.odin, lines 10 to 33
read_dotenv :: proc() -> (contents: string, ok: bool) {
	file, err := os.open(".env", {.Read, .Non_Blocking})
	if err == .Not_Exist {
		return "", true
	}
	if err != nil {
		fmt.eprintfln("[!] Cannot read .env: %s", os.error_string(err))
		return
	}
	defer os.close(file)
	info, stat_err := os.fstat(file, context.allocator)
	if stat_err != nil || info.type != .Regular || info.size > MAX_DOTENV_BYTES {
		fmt.eprintln("[!] Cannot read .env: expected a regular file no larger than 64 KiB")
		return
	}
	buffer := make([]byte, MAX_DOTENV_BYTES+1)
	total := 0
	for total < len(buffer) && !cancelled() {
		n, read_err := os.read(file, buffer[total:])
		total += n
		if read_err != nil && read_err != .EOF {
			fmt.eprintfln("[!] Cannot read .env: %s", os.error_string(read_err))
			return
		}
// Excerpt ends here.

166 lines in the file. SHA-256 7f7a5f5ce215e0952d934a29c2f7ab2d92f4102059714815c5123644f1433084

Control the collection

collector.odin

Build filters, visit pages in order, reject duplicate IDs and decide when to publish.

Response data and formatted CSV fields die with the page arena. Nonempty IDs are copied into the collection allocator, so duplicate detection survives the next page.

Owns
Collection-wide IDs and one arena for each page.
Procedures
build_filters, collect_page, collect
Read a source excerpt collector.odin, lines 13 to 22
build_filters :: proc(options: Options) -> string {
	parts: [dynamic]string
	if !options.no_code {
		append(&parts, "_tags:founders-code-yes")
	}
	if options.revenue > 0 {
		append(&parts, fmt.aprintf("revenue >= %d", options.revenue))
	}
	return strings.join(parts[:], " AND ")
}

113 lines in the file. SHA-256 7dbcb98d913bf5c99bf8a6ebf5e8bb2f1ae2b9a55b80f64e4d476d9545cccbae

Send one search request

http.odin

Reuse a native libcurl handle, send authentication headers and collect a bounded response.

The request has a total 15-second deadline. TLS verification stays enabled. Redirects fail. No application retry runs after an error. The 16 MiB body limit applies after decompression.

Owns
The curl handle, header list, URL and transfer buffer.
Procedures
api_origin, http_init, configure_http, fetch_page
Read a source excerpt http.odin, lines 120 to 142
configure_http :: proc(handle: ^curl.CURL, url: string, headers: ^curl.slist) -> bool {
	checks := [?]curl.code{
		curl.easy_setopt(handle, .URL, strings.clone_to_cstring(url)),
		curl.easy_setopt(handle, .HTTPHEADER, headers),
		curl.easy_setopt(handle, .USERAGENT, cstring("IndieHackers-Data-Collector/2.0")),
		curl.easy_setopt(handle, .TIMEOUT_MS, c.long(15_000)),
		curl.easy_setopt(handle, .CONNECTTIMEOUT_MS, c.long(15_000)),
		curl.easy_setopt(handle, .NOSIGNAL, c.long(1)),
		curl.easy_setopt(handle, .FOLLOWLOCATION, c.long(0)),
		curl.easy_setopt(handle, .PROTOCOLS_STR, cstring("http,https")),
		curl.easy_setopt(handle, .WRITEFUNCTION, curl.write_callback(receive_body)),
		curl.easy_setopt(handle, .XFERINFOFUNCTION, curl.xferinfo_callback(transfer_progress)),
		curl.easy_setopt(handle, .NOPROGRESS, c.long(0)),
		curl.easy_setopt(handle, .ACCEPT_ENCODING, cstring("")),
	}
	for result in checks {
		if result != .E_OK {
			fmt.eprintfln("[!] Cannot configure HTTP: %s", curl.easy_strerror(result))
			return false
		}
	}
	return true
}

186 lines in the file. SHA-256 89f98fbe40906f84fb4b0d02656e5a088f985d15cb1620564b9029ece16cf1a8

Reject malformed JSON

json.odin

Use Odin's tokenizer and string decoder with strict grammar and resource checks.

Duplicate decoded keys, trailing commas, invalid UTF-8 and unpaired surrogates fail. Integers must fit signed 64 bits. Floats must be finite. Depth and token budgets bound the parser.

Owns
A bounded reader and values allocated in the current page arena.
Procedures
json_advance, json_string, json_read_value, decode_json
Read a source excerpt json.odin, lines 190 to 199
decode_json :: proc(body: []byte) -> (json.Value, bool) {
	reader := JSON_Reader{
		tokenizer = json.make_tokenizer(string(body), .JSON, parse_integers = true),
		ok = true,
		remaining = 200_000,
	}
	json_advance(&reader)
	value := json_read_value(&reader, 0)
	return value, reader.ok && reader.token.kind == .EOF
}

199 lines in the file. SHA-256 790f131fbc1b1425a683933853200b12ce208836733586b95e305ec69d848c8a

Turn hits into CSV fields

response.odin

Validate page metadata, check scalar fields and encode each product ID as one URL segment.

The response needs hits and nbPages. A supplied page must match the request. Missing fields use the original script's defaults; explicit nulls become empty strings.

Owns
A search page view and six strings for each row.
Procedures
decode_page, csv_field, decode_product
Read a source excerpt response.odin, lines 63 to 82
decode_product :: proc(value: json.Value) -> (row: Product_Row, ok: bool) {
	hit, is_object := value.(json.Object)
	if !is_object {
		fmt.eprintln("[!] Invalid product: each hit must be an object")
		return
	}
	keys := [?]string{"name", "tagline", "revenue", "objectID", "websiteUrl", "status"}
	defaults := [?]string{"N/A", "", "0", "", "", "unknown"}
	for key, i in keys {
		field, valid := csv_field(hit, key, defaults[i])
		if !valid {
			fmt.eprintfln("[!] Invalid product: %s must be a scalar or null", key)
			return
		}
		row.fields[i] = field
	}
	row.id = row.fields[3]
	row.fields[3] = fmt.aprintf("https://www.indiehackers.com/product/%s", net.percent_encode(row.id))
	return row, true
}

82 lines in the file. SHA-256 9413d946c62d44899d68d73b34119d37ff9ff74c57c373b58fac92b592d3b6e0

Publish the CSV

export.odin

Lock the output directory, write private staging data and check identities before rename.

A nonblocking directory lock coordinates collectors. Cleanup removes only the staging inode created by this process. The final rename is atomic; a following directory-sync error reports that publication already happened.

Owns
Directory and file descriptors, the CSV buffer and filesystem snapshots.
Procedures
export_begin, export_write, export_commit, export_discard
Read a source excerpt export.odin, lines 143 to 159
export_unchanged :: proc(export: ^Export) -> bool {
	directory_info: posix.stat_t
	if posix.stat(export.directory_path, &directory_info) != .OK ||
	   directory_info.st_dev != export.directory_info.st_dev || directory_info.st_ino != export.directory_info.st_ino {
		fmt.eprintln("[!] Output directory changed during collection; export discarded")
		return false
	}
	current, exists, ok := export_snapshot(export)
	if !ok {
		return false
	}
	if exists != export.existed || exists && !same_file_snapshot(current, export.original) {
		fmt.eprintln("[!] CSV destination changed during collection; export discarded")
		return false
	}
	return true
}

239 lines in the file. SHA-256 92012f3169dccecf067baa9f2ce29b6f3e488bfa3fc97a381372759a14f2728e

Handle an interrupt

cancel.odin

Record SIGINT or SIGTERM in lock-free state and interrupt transfers and page pacing.

The signal handler allocates nothing and performs no I/O. Collection observes the flag during work. The final publication block defers these signals while it finishes the commit.

Owns
One atomic signal value.
Procedures
interrupt_handler, cancelled, install_interrupts, pause_between_pages
Read a source excerpt cancel.odin, lines 10 to 13
interrupt_handler :: proc "c" (signal: posix.Signal) {
	// Signal handlers may only touch lock-free state; no allocation or I/O.
	intrinsics.atomic_store_explicit(&interrupted, i32(signal), .Relaxed)
}

49 lines in the file. SHA-256 5b6dd16107e8657cc179b07af0682c674dc633644e9ec30b9b5b36d654019edf

One page's memory

HTTP bytes, decoded JSON and formatted fields live in a page arena. The collector destroys that arena after the page is written.

The collection's memory

Cloned nonempty IDs survive across pages for duplicate detection. One curl handle and one export transaction survive until collection ends.

What runs where.

The CLI and this guide have separate runtimes. Browsing this page does not run the collector.

Compile

Odin

Official monthly release dev-2026-10, tested on Linux x86-64.

  • Clang links the executable.
  • -o:speed -vet -strict-style
  • No package manager for the CLI.

Collect

Native process

The vendor:curl bindings call the system libcurl.

  • libcurl, zlib and system CA certificates
  • Odin JSON tokenizer and CSV encoder
  • Linux descriptors, locks and rename

Verify

Real executable

Node's test runner spawns the collector against loopback servers and isolated files.

  • Node.js 22 or later
  • strace and OpenSSL
  • AddressSanitizer for memory checks

Odin's release tags still use dev. The monthly dev-2026-10 release was the latest marked non-prerelease when checked on 17 October 2026. The archive reports dev-2026-10-nightly:84bc3fc internally.

What changed from the Python script?
ConcernReference scriptOdin rewrite
HTTPrequestsOne native libcurl client with a deadline and decoded body limit
Configurationpython-dotenvA bounded reader with explicit environment precedence
Rowspandas DataFrameSix fields per row through Odin's CSV encoder
MemoryA collected product listPage arenas plus persistent duplicate IDs
OutputA direct CSV writePrivate staging, identity checks, atomic rename and directory sync
FailuresScript-level error handlingDefined exit codes, cancellation and executable regression tests

Choose the query.
Read the command.

Change the controls to inspect the actual filter expression. Copy the command to run it on your own machine.

Collector options

A revenue value of zero or less removes the revenue filter.

Zero pages skips collection. Page numbers in the API start at zero.

Your command
./build/collector --revenue 1 --pages 5 --output='products.csv'

Algolia filter

_tags:founders-code-yes AND revenue >= 1

At most 5 pages, with up to 100 hits per page. Algolia determines the accessible result window.

These controls only generate text. The collector reads ALGOLIA_APP_ID and ALGOLIA_API_KEY from your environment or local .env. Keep both out of commands you share.

All flags and exit codes
FlagDefaultBehavior
--revenue INTEGER1Inclusive minimum, signed 64-bit. Zero or negative disables this filter.
--no-codeOffRemove _tags:founders-code-yes.
--pages INTEGER5Nonnegative maximum. Zero skips collection.
--output FILEproducts.csvParent directory must already exist. Use --output=VALUE for a name starting with a dash.
--base-url URLAlgolia originHTTP or HTTPS origin for testing, with no path, query or userinfo. Sends the configured authentication headers to that server.
--help, -hPrint usage without credentials.

0 success or empty result / 1 runtime or configuration failure / 2 invalid arguments / 130 SIGINT / 143 SIGTERM

Value flags accept a space or an equals sign. SIGINT and SIGTERM can finish with success if publication has already entered its protected commit.

The rename is
the turning point.

Step through an export with an existing CSV. Pick a failure to see which file remains. This is an explanatory model of the implementation.

Lock directoryStage rowsFlush and syncCheck identitiesRenameSync directory
Step 1 of 6

Lock the output directory.

Open the directory and acquire a nonblocking advisory lock. Snapshot the existing destination and create a private staging inode with mode 0600.

Collection is in progress.

products.csvPrevious export
Unchanged
.collector-<nonce>Private staging file
0600

The file names are illustrative. Staging uses a cryptographic 32-character hexadecimal nonce.

Before rename. Handled errors, cancellation and empty results preserve the destination. Cleanup checks the staging inode before unlinking. A foreign replacement stays untouched.

After rename. The new file is already published. If directory sync then fails, the process returns 1 and says publication happened. It does not claim to have restored the old file.

Concurrency, crashes and permissions

The directory lock permits one collector per output directory, even if collectors choose different filenames. It is advisory. Unrelated programs that ignore the lock are not serialized.

The collector checks the directory identity, destination snapshot and staging inode before rename. New destinations use a no-replace rename. New files start at mode 0600. Replacements copy the old file's ordinary permission bits. The new inode's owner and group follow the creating process and directory.

SIGINT and SIGTERM use a signal flag and normal cleanup. SIGKILL, a crash or refused cleanup can leave private staging files. A later run does not sweep those files. The kernel releases the directory lock when the process dies.

The contract,
including its edges.

The CSV preserves the reference's shape. The parser and transport impose explicit limits before a row can reach publication.

Six columns, in order

  1. NamenameMissing → N/A
  2. TaglinetaglineMissing → empty
  3. Monthly Revenue ($)revenueMissing → 0
  4. IndieHackers URLobjectIDMissing → empty URL suffix
  5. WebsitewebsiteUrlMissing → empty
  6. StatusstatusMissing → unknown

Explicit nulls become empty strings. Objects and arrays fail in these fields. Numbers format individually; booleans become True or False. CSV uses UTF-8 and LF line endings.

Bounded work

HTTP deadline
15 seconds per request
Page spacing
1 second between pages
Response body
16 MiB after decompression
Search hits
At most 100 per response
JSON nesting
128 levels
JSON tokens
200,000 per response
Local .env
64 KiB, regular UTF-8 file
CSV buffer
16 KiB, checked writes

What has not been proved

A successful live IndieHackers export has not been verified. No live credentials are bundled. The products page returned a browser challenge during inspection. A real Algolia request with a deliberately invalid key returned 403, and the collector preserved its prior export.

Algolia controls the accessible result window. Paging does not promise a full index snapshot. CSV preserves raw source text, including formula-like strings that a spreadsheet may evaluate. The collector has no scheduler, database or resume checkpoint.

Seven passes.
Two clean at the end.

Each verified finding led to a repair and another check. A new finding reset the clean count. The review stopped after passes six and seven found no new issues.

137Executable scenarios139 reported runner tests
108AddressSanitizer testsNo sanitizer reports
64 / 88JSON corpus bodiesValid / malformed inputs

Recorded on 17 October 2026. These checks use synthetic data. They exercise the real compiled collector, not an in-browser implementation.

1Strict input and export rules9 finding groups0/2 clean

Fixed malformed JSON acceptance, permission changes, symlink destinations, ambiguous flags, dot-segment indexes, product URLs, duplicate IDs, partial HTTP responses and comment-only dotenv values.

95 end-to-end scenarios after repairs.

2Process and filesystem lifecycle4 finding groups0/2 clean

Fixed destination edits during collection, handled cancellation, FIFO or oversized configuration files, and missing directory sync. One apparent short-write issue was withdrawn after the probe proved invalid.

114 scenarios after repairs. Fault injection checked the intended syscalls.

3Encoding and API origins2 finding groups0/2 clean

Rejected invalid UTF-8 in configuration and malformed API origins. Valid IPv6 origins and a trailing root slash have positive coverage.

123 end-to-end scenarios after repairs.

4Parser corpus and transportNo new findings1/2 clean

Reviewed an independent JSON corpus, TLS rejection, decoded transfer limits, signals at the commit boundary and memory instrumentation. No new verified findings.

132 scenarios and 108 AddressSanitizer tests passed. First clean pass.

5Staging ownership1 finding groups0/2 clean

A replaced staging pathname could publish foreign contents. Added inode, metadata and file-extent checks. Cleanup now leaves a foreign replacement untouched and reports it.

136 scenarios after repairs. The clean count reset to zero.

6Ownership and recoveryNo new findings1/2 clean

Followed callback pointers, page arenas, persistent IDs, descriptors, cancellation and crash recovery. No new verified findings.

108 AddressSanitizer tests and 24 filesystem or cancellation scenarios passed.

7Final contract and fresh buildNo new findings2/2 clean

Reviewed CLI behavior, configuration, response rules, permissions, signal exits and both sides of publication. A real Algolia HTTPS request with an invalid key failed without replacing the prior export.

137 scenarios, reported as 139 runner tests, passed. Two consecutive clean passes.

How the tests challenge the executable

tests/e2e.mjs covers requests, filters, pagination, pacing, CSV output, configuration and error behavior. tests/lifecycle.mjs exercises signals, overlapping collectors, changed paths, file-size limits and syscall failures.

tests/protocol.mjs checks TLS rejection, truncated transfers, decompressed limits, connection reuse and signals at publication syscalls. tests/json-corpus.mjs uses independent JSON and CSV consumers as comparison points.

The review withdrew one apparent short-write finding. A positive return injected with strace skipped the write without creating its claimed prefix. That probe did not represent a real short write, so it is not a regression test or a counted finding.

Read the full review record

Run it on Linux.

Use the tested monthly Odin compiler, Clang, libcurl, zlib and a CA certificate store. Run the commands from the repository directory.

1

Build the executable

Install Odin dev-2026-10 and put odin and clang on your PATH.

odin version
make
./build/collector --help
2

Configure your search credentials

Copy .env.example to .env. Replace the placeholders with valid search-only credentials from your normal browser requests on the products page.

ALGOLIA_APP_ID=your_app_id_here
ALGOLIA_API_KEY=your_search_only_api_key_here
ALGOLIA_INDEX=products

The environment overrides the local file, including an empty value. The index defaults to products only when unset. A malformed local configuration can still fail startup.

3

Collect and verify

The default query includes founders who code, at least $1 in monthly revenue and a maximum of five pages.

./build/collector

# Tests require Node.js 22+, strace and OpenSSL.
make check
make test

A new CSV uses private permissions. The output directory must already exist. Empty results leave any previous export untouched.

A guide beside the executable.

This page is static HTML, CSS and browser JavaScript. A Node build reads an allowlist of source files and inserts the module excerpts. Caddy serves the published files over HTTPS at indiehacker.plosca.ru.

The guide has no collector backend, account system or analytics. Fonts are hosted here. The diagrams and controls work locally in your browser. A versioned release directory keeps the published guide separate from the working repository.

Source snapshot
09edccf22d5e
Collector review date
Snapshot details
Source hashes and verification counts

The source hash identifies the eight Odin files used for these excerpts. The review date and counts describe that recorded verification, not a live monitoring service.