Automating Blog Posts from VibeCode Sessions with OpenCode’s Free AI API #
TL;DR
- OpenCode’s Zen API at
opencode.ai/zen/v1/chat/completionsprovides free access to models likebig-pickleusing the “public” API key - The key discovery was that passing
max_tokenscauses models to output to thereasoningfield instead ofcontent— removing this parameter fixes the issue - Use
jq --rawfilefor passing large files to the API (not--slurpfilewhich expects JSON format) - The opencode-cli works without an API key because it uses
apiKey: "public"which the server accepts for free models withallowAnonymous: true
Introduction #
In this session, I worked on automating the conversion of VibeCode session transcripts into blog posts using OpenCode’s free AI API. The journey involved discovering how the opencode-cli authenticates without requiring an API key, debugging API request format issues, and ultimately creating a working bash script that transforms raw transcripts into formatted markdown blog posts.
The key challenge was understanding how the local opencode server proxies requests to app.opencode.ai and how free models are accessible without explicit authentication. Through source code exploration and curl testing, I uncovered the authentication mechanism and resolved several technical hurdles.
Understanding OpenCode’s Authentication System #
The Local Server Proxy Architecture #
The opencode-cli application consists of a local server that handles all API requests by proxying them to app.opencode.ai. When you run opencode in your terminal, the local server intercepts the requests and forwards them to the cloud service, passing through the authentication headers.
From the source code in opencode/packages/opencode/src/server/server.ts, we can see how the proxy works:
.all("/*", async (c) => {
const path = c.req.path
const response = await proxy(`https://app.opencode.ai${path}`, {
...c.req,
headers: {
...c.req.raw.headers,
host: "app.opencode.ai",
},
})
// ... response handling
})This means all requests to the local server are transparently forwarded to the cloud API.
The “Public” API Key Discovery #
The most interesting discovery was how the CLI works without requiring users to configure an API key. Looking at opencode/packages/opencode/src/provider/provider.ts:
async opencode(input) {
const hasKey = await (async () => {
const env = Env.all()
if (input.env.some((item) => env[item])) return true
if (await Auth.get(input.id)) return true
const config = await Config.get()
if (config.provider?.["opencode"]?.options?.apiKey) return true
return false
})()
if (!hasKey) {
for (const [key, value] of Object.entries(input.models)) {
if (value.cost.input === 0) continue
delete input.models[key]
}
}
return {
autoload: Object.keys(input.models).length > 0,
options: hasKey ? {} : { apiKey: "public" },
}
}When no API key is configured, the CLI uses apiKey: "public". The server then validates this against models that have allowAnonymous: true, which includes free models like big-pickle, glm-5-free, and minimax-m2.5-free.
Testing the API with Curl #
First Attempt: Using the Real API Key #
Initially, we tested the OpenCode Zen API with a real API key to understand the request format:
curl -s -X POST "https://opencode.ai/zen/v1/chat/completions" \
-H "Authorization: Bearer CENSORED" \
-H "Content-Type: application/json" \
-d '{
"model": "big-pickle",
"messages": [{"role": "user", "content": "Say exactly: Hello world"}],
"max_tokens": 10
}' | jq '.'The response showed that even with the model specified as “big-pickle”, it was being routed to “minimax/minimax-m2.5” internally:
{
"id": "gen-1771727853-JIhkIVvZzrH8VtaisJLv",
"provider": "Minimax",
"model": "minimax/minimax-m2.5",
"object": "chat.completion",
"created": 1771727853,
"choices": [
{
"message": {
"content": "",
"reasoning": "The user asks: \"Say exactly:"
}
}
],
"usage": {
"prompt_tokens": 46,
"completion_tokens": 10,
"total_tokens": 56,
"cost": 0
}
}The max_tokens Problem #
We noticed that when max_tokens was set to a small value like 10, the model outputted to the reasoning field instead of the content field. The response contained empty content with reasoning text like “The user asks: ‘Say exactly:’”.
Testing with “public” API Key #
After the user discovered the issue and asked us to test with the “public” API key, here’s the curl request and result:
curl -s -X POST "https://opencode.ai/zen/v1/chat/completions" \
-H "Authorization: Bearer public" \
-H "Content-Type: application/json" \
-d '{
"model": "big-pickle",
"messages": [{"role": "user", "content": "Say hello in exactly 3 words"}],
"temperature": 0.7
}'The response was successful:
{
"id": "gen-1771731836-v4DkbM3VbTxXl5zygN4K",
"provider": "Minimax",
"model": "minimax/minimax-m2.5",
"object": "chat.completion",
"choices": [
{
"message": {
"role": "assistant",
"content": "Hello there, friend!"
}
}
],
"usage": {
"prompt_tokens": 48,
"completion_tokens": 349,
"total_tokens": 397,
"cost": 0,
"is_byok": true
}
}The response contained “Hello there, friend!” — exactly 3 words as requested. This confirms the “public” API key works perfectly for the big-pickle model.
The jq Rawfile vs Slurpfile Issue #
The Problem #
When building the script to process large transcript files, we initially used --slurpfile in jq to read the transcript:
jq -n \
--arg model "big-pickle" \
--rawfile prompt "$TMPDIR/prompt.txt" \
--slurpfile transcript "$EXPORT_FILE" \
'{
model: $model,
messages: [{role: "user", content: ($prompt + "\n\nTranscript:\n" + ($transcript | join("\n")))}],
temperature: 0.7
}' > "$TMPDIR/payload.json"This resulted in an error:
jq: Bad JSON in --slurpfile transcript session-ses_4333.dh.md: Invalid numeric literal at line 1, column 2The Solution #
The issue was that --slurpfile expects JSON input, but our transcript is plain text. The correct approach is to use --rawfile which reads the file as a raw string:
# First truncate the transcript to avoid JSON parsing issues (limit to 80k chars)
head -c 80000 "$EXPORT_FILE" > "$TMPDIR/transcript.txt"
# Build JSON payload using --rawfile
jq -n \
--arg model "big-pickle" \
--rawfile prompt "$TMPDIR/prompt.txt" \
--rawfile transcript "$TMPDIR/transcript.txt" \
'{
model: $model,
messages: [{role: "user", content: ($prompt + "\n\nTranscript:\n" + $transcript)}],
temperature: 0.7
}' > "$TMPDIR/payload.json"This correctly passes the transcript as a string to the API.
Available Models on OpenCode Zen API #
From the source code documentation at opencode/packages/web/src/content/docs/zen.mdx, here are the available models and their endpoints:
| Model Name | Model ID | Endpoint | SDK |
|---|---|---|---|
| GPT 5.2 | gpt-5.2 | /v1/responses |
@ai-sdk/openai |
| Claude Sonnet 4.6 | claude-sonnet-4-6 | /v1/messages |
@ai-sdk/anthropic |
| Claude Haiku 4.5 | claude-haiku-4-5 | /v1/messages |
@ai-sdk/anthropic |
| Gemini 3 Pro | gemini-3-pro | /v1/models/gemini-3-pro |
@ai-sdk/google |
| MiniMax M2.5 | minimax-m2.5 | /v1/chat/completions |
@ai-sdk/openai-compatible |
| Big Pickle | big-pickle | `/v1/chat/completions** | @ai-sdk/openai-compatible |
| GLM 5 | glm-5 | /v1/chat/completions |
@ai-sdk/openai-compatible |
| Kimi K2.5 | kimi-k2.5 | /v1/chat/completions |
@ai-sdk/openai-compatible |
Models marked in bold are free to use with the “public” API key.
The Final Working Script #
Here’s the final version of vibecode-to-blog.sh that successfully converts VibeCode transcripts to blog posts:
#!/bin/bash
# Process OpenCode /export transcript to blog post
# Usage: ./vibecode-to-blog.sh /path/to/export.txt "Post Title"
set -e
if [ -z "$1" ] || [ -z "$2" ]; then
echo "Usage: $0 <export_file> <title> [date]"
echo " date: optional, defaults to today (YYYY-MM-DD)"
exit 1
fi
EXPORT_FILE="$1"
TITLE="$2"
DATE="${3:-$(date +%Y-%m-%d)}"
DATE_DISPLAY=$(date -d "$DATE" +%Y.%m.%d)
DATE_TZ=$(date -d "$DATE" +%Y-%m-%dT%H:%M:%S%z)
SLUG=$(echo "$TITLE" | tr '[:upper:]' '[:lower:]' | tr ' ' '-' | tr -cd '[:alnum:]-')
if [ -z "$OPENCODE_API_KEY" ] && [ -z "$GROQ_API_KEY" ]; then
echo "Error: Set OPENCODE_API_KEY or GROQ_API_KEY environment variable"
exit 1
fi
OUTPUT_DIR="blog/content/vibecode"
mkdir -p "$OUTPUT_DIR"
OUTPUT_FILE="$OUTPUT_DIR/${DATE_DISPLAY}-${SLUG}.md"
echo "Processing: $EXPORT_FILE"
echo "Output: $OUTPUT_FILE"
# Priority: OpenCode API (Big Pickle - free, high limits)
if [ -n "$OPENCODE_API_KEY" ]; then
echo "Using OpenCode API (Big Pickle)..."
TMPDIR=$(mktemp -d)
# Truncate transcript to avoid JSON parsing issues (limit to 80k chars)
head -c 80000 "$EXPORT_FILE" > "$TMPDIR/trans echo "Note:cript.txt"
Transcript truncated to 80k chars for API"
# Build prompt
cat > "$TMPDIR/prompt.txt" << 'PROMPT'
You are a technical writer. Convert this VibeCode session transcript into a detailed, comprehensive blog post.
CRITICAL: Do NOT summarize - write FULL detailed content with all code examples, explanations, and technical details from the transcript.
Requirements:
1. Write a detailed introduction (3-4 sentences)
2. Add a TLDR section with key takeaways (3-5 bullet points)
3. Create multiple sections with headings covering each topic from the transcript
4. Include ALL code snippets shown in the transcript
5. Add explanations before and after code blocks
6. Add a detailed summary section
7. Use markdown formatting extensively
Output the FULL blog post content - do not abbreviate or summarize.
PROMPT
# Build JSON payload
jq -n \
--arg model "big-pickle" \
--rawfile prompt "$TMPDIR/prompt.txt" \
--rawfile transcript "$TMPDIR/transcript.txt" \
'{
model: $model,
messages: [{role: "user", content: ($prompt + "\n\nTranscript:\n" + $transcript)}],
temperature: 0.7
}' > "$TMPDIR/payload.json"
RESPONSE=$(curl -s -X POST \
"https://opencode.ai/zen/v1/chat/completions" \
-H "Authorization: Bearer $OPENCODE_API_KEY" \
-H "Content-Type: application/json" \
-d @"$TMPDIR/payload.json")
rm -rf "$TMPDIR"
CONTENT=$(echo "$RESPONSE" | jq -r '.choices[0].message.content // empty')
elif [ -n "$GROQ_API_KEY" ]; then
echo "Using Groq API..."
# ... Groq implementation
fi
if [ -z "$CONTENT" ]; then
echo "Error: Failed to get response from API"
echo "$RESPONSE"
exit 1
fi
cat > "$OUTPUT_FILE" << EOF
+++
title = '$TITLE'
date = '$DATE_TZ'
draft = true
categories = ['VibeCode']
tags = []
description = ""
+++
$CONTENT
EOF
echo "Done! Edit: $OUTPUT_FILE"
echo "Run 'hugo -d /var/www/locals.tf/blog' to rebuild"Key Takeaways #
-
Use “public” as the API key for free models on OpenCode’s Zen API — this works because the server allows anonymous access to models with
allowAnonymous: true -
Don’t set max_tokens when using models like big-pickle/minimax — this causes output to go to the
reasoningfield instead ofcontent, resulting in empty responses -
Use jq –rawfile for large text inputs instead of
--slurpfilewhich expects JSON input and will fail with plain text files -
The endpoint format matters — OpenAI-compatible models use
/v1/chat/completions, Claude models use/v1/messages, and GPT models use/v1/responses -
The opencode-cli uses Basic Auth for local server communication with
getAuthorizationHeader()returningBasic ${btoa(${username}:${password})}when a server password is configured
Conclusion #
This session demonstrated how to leverage OpenCode’s free Zen API for automated blog post generation. By understanding the authentication mechanism (using “public” for anonymous free access), the correct request format (omitting max_tokens), and proper file handling with jq (using –rawfile), we created a reliable script for converting VibeCode transcripts into formatted blog posts.
The discovery that the opencode-cli doesn’t require an API key because it uses “public” as a placeholder is particularly useful for development and testing purposes. This makes it easy to get started without configuring credentials, while still maintaining access to powerful free models like big-pickle.