<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Oleg Jakunin</title>
    <description>The latest articles on DEV Community by Oleg Jakunin (@jakuninoleg).</description>
    <link>https://dev.to/jakuninoleg</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4045482%2Fdf5ac60b-ff43-49d2-85b7-7f5f7da261e4.jpg</url>
      <title>DEV Community: Oleg Jakunin</title>
      <link>https://dev.to/jakuninoleg</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jakuninoleg"/>
    <language>en</language>
    <item>
      <title>I built OJ CMS because the editor should not have to understand my website</title>
      <dc:creator>Oleg Jakunin</dc:creator>
      <pubDate>Mon, 12 Oct 2026 11:39:00 +0000</pubDate>
      <link>https://dev.to/jakuninoleg/i-built-oj-cms-because-the-editor-should-not-have-to-understand-my-website-5dmp</link>
      <guid>https://dev.to/jakuninoleg/i-built-oj-cms-because-the-editor-should-not-have-to-understand-my-website-5dmp</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2ndf74xs36asn4gs6uc2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2ndf74xs36asn4gs6uc2.png" alt="Developer assembling the OJ CMS interface on a Payload engine" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Open a CMS. Find the right section. Figure out which field actually appears on the page. Hope changing it does not disturb the layout.&lt;/p&gt;

&lt;p&gt;That is a lot of background knowledge for someone who wants to update a product description.&lt;/p&gt;

&lt;p&gt;When I built OJ CMS, I wanted to remove that background knowledge from the job. The editor should recognize the content and know what happens after saving it. The way I assemble the website should mostly be my problem.&lt;/p&gt;

&lt;p&gt;OJ CMS is my custom administration interface on top of Payload. The public site uses Next.js and React. Content, inquiries and selected traffic information can live in the same workspace, with the interface adapted to the project.&lt;/p&gt;

&lt;h2&gt;
  
  
  The WordPress habit I wanted to break
&lt;/h2&gt;

&lt;p&gt;I do not like starting a custom website by searching for a theme and then searching for plugins to repair the mismatch.&lt;/p&gt;

&lt;p&gt;A catalog needs documents connected to products. A product needs specifications. An editor needs to know whether a record is published. None of that starts as a theme decision.&lt;/p&gt;

&lt;p&gt;Yet it is easy to make the theme the center of the project. Then the business gets expressed through whatever the theme and its extensions happen to support. A requirement becomes a search for another plugin. A design change becomes an exception to someone else's layout system.&lt;/p&gt;

&lt;p&gt;That is the default I wanted to leave behind. I want to decide what the site does, model its content and build its interface around that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Writing your own software does not mean writing every layer
&lt;/h2&gt;

&lt;p&gt;I did not write a new authentication system to prove that I am a developer. I did not invent a database abstraction for the same reason.&lt;/p&gt;

&lt;p&gt;Payload provides the CMS foundation. I use it to describe content and build the behavior around it. OJ CMS is the layer where I make the editor experience specific.&lt;/p&gt;

&lt;p&gt;Think about attaching a document to a product. That relationship should be part of the content model. The editor can select the document, and the website knows where to show it. Nobody needs to remember which text block contains the download link.&lt;/p&gt;

&lt;p&gt;That sounds mundane. Good. Updating a catalog should be mundane.&lt;/p&gt;

&lt;h2&gt;
  
  
  The small decisions matter more than the dashboard screenshot
&lt;/h2&gt;

&lt;p&gt;A dashboard can look impressive while telling you very little about what to do next.&lt;/p&gt;

&lt;p&gt;I care more about whether an editor can find the record they just changed. Whether the interface makes a draft obvious. Whether leaving a page with unsaved changes feels different from leaving a saved page. Whether a media item already used by the website can be mistaken for something disposable.&lt;/p&gt;

&lt;p&gt;Incoming inquiries belong close to the content that prompted them. Traffic information is useful when it helps someone decide what to update, rather than turning the admin into a wall of charts.&lt;/p&gt;

&lt;p&gt;The design work is partly about removing irrelevant choices. An editor managing news and products does not need a miniature tour of my technology stack every morning.&lt;/p&gt;

&lt;h2&gt;
  
  
  A custom frontend gives me somewhere to put responsibility
&lt;/h2&gt;

&lt;p&gt;I want control over the public website too. Its layout, interaction and mobile behavior should follow the project, not the limitations of a purchased theme.&lt;/p&gt;

&lt;p&gt;With structured content, I can change how a product page renders without rebuilding its content. The same data can support a category listing, a detail page and a search result. An editor does not have to create three versions of the same information.&lt;/p&gt;

&lt;p&gt;There is also less room to dodge the engineering work. If I ship oversized images or unnecessary JavaScript, that is mine to fix. If publishing does not update the website correctly, I need to fix that path. Installing a modern framework does not excuse any of those things.&lt;/p&gt;

&lt;p&gt;That accountability is part of why I prefer this approach.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa0p62zu2huk2cffdnazc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa0p62zu2huk2cffdnazc.png" alt="OJ CMS responsibility map: custom interface, server access checks, Payload, website, database and external analytics" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The custom screen still needs real permissions
&lt;/h2&gt;

&lt;p&gt;One detail for anyone trying this with Payload: the Local API overrides access control by default. A custom server operation acting for an editor needs to opt into the access checks and pass the authenticated user.&lt;/p&gt;

&lt;p&gt;Hiding an action in React is not permission enforcement. The server still has to decide whether the user can perform it. &lt;a href="https://payloadcms.com/docs/local-api/overview" rel="noopener noreferrer"&gt;Payload documents the Local API options here&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;This is the kind of complexity worth keeping in the code, rather than pushing into an editor's daily work.&lt;/p&gt;

&lt;h2&gt;
  
  
  What about EmDash?
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/emdash-cms/emdash" rel="noopener noreferrer"&gt;EmDash&lt;/a&gt; is worth a look if you are exploring an Astro-based CMS. It offers another route into modern TypeScript publishing, including sandboxed plugins.&lt;/p&gt;

&lt;p&gt;OJ CMS uses Payload. I am interested in EmDash because it asks some of the same underlying questions about how a CMS and its extensions should be put together. I would compare the editorial workflow and deployment requirements before picking a foundation for a new project.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with the thing your editor is trying to do
&lt;/h2&gt;

&lt;p&gt;I am not interested in rebuilding familiar CMS features just to put my name on them. I am interested in the awkward gap between a generic admin and a particular website.&lt;/p&gt;

&lt;p&gt;For OJ CMS, that meant treating products, documents, news and inquiries as things the interface understands. It meant keeping the public design independent of the editor. It also meant accepting that a custom interface is software I have to maintain.&lt;/p&gt;

&lt;p&gt;The question I would ask before adding another plugin is: are we implementing the actual task, or teaching someone another workaround?&lt;/p&gt;

&lt;p&gt;Which part of your current CMS makes editors learn something they should not need to know?&lt;/p&gt;




&lt;p&gt;Writing and illustrations prepared with AI assistance.&lt;/p&gt;

</description>
      <category>webdev</category>
    </item>
    <item>
      <title>I Kept Rebuilding LLM Provider Setup - So I Built a Small Go Gateway</title>
      <dc:creator>Oleg Jakunin</dc:creator>
      <pubDate>Tue, 15 Sep 2026 19:00:37 +0000</pubDate>
      <link>https://dev.to/jakuninoleg/i-kept-rebuilding-llm-provider-setup-so-i-built-a-small-go-gateway-5e06</link>
      <guid>https://dev.to/jakuninoleg/i-kept-rebuilding-llm-provider-setup-so-i-built-a-small-go-gateway-5e06</guid>
      <description>&lt;p&gt;There is a particular point in an AI feature where my energy tends to drop. The idea is working. I have a screen or a route I want to test. The prompt is good enough to learn something from a real response.&lt;/p&gt;

&lt;p&gt;And then I am not testing the feature anymore.&lt;/p&gt;

&lt;p&gt;I am finding the right provider key for this project, checking whether a model name still works, comparing a streaming response to a non-streaming one, and wondering whether the environment where I deployed the app can use the provider at all. None of that is dramatic work. It is just the same surrounding setup, again and again, sitting between me and the question I actually wanted to answer: does this feature help a user?&lt;/p&gt;

&lt;p&gt;That repetition is why I built &lt;strong&gt;Go-Ai&lt;/strong&gt;, a small HTTP gateway for my own projects. It gives application backends one stable place to send chat-completion requests while the gateway deals with provider-facing details. Its boundaries are deliberate: it is not a general AI platform, agent runtime, or replacement for the application that calls it.&lt;/p&gt;

&lt;p&gt;Source: &lt;a href="https://github.com/JakuninOleg/Go-Ai" rel="noopener noreferrer"&gt;github.com/JakuninOleg/Go-Ai&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I also wanted a real reason to spend time with Go. I had heard good things about it for years, but did not want to learn it through comparisons or a throwaway demo. A compact service with HTTP, configuration, streaming, and clear failure cases gave me a useful problem. Go was a pragmatic choice, not a claim that it is better than every alternative.&lt;/p&gt;

&lt;h2&gt;
  
  
  The friction was around the feature, not inside it
&lt;/h2&gt;

&lt;p&gt;The clearest example for me was a development app where I kept trying chat, streaming, and later voice. Every improvement to the product surface meant revisiting the same provider chores: which key belongs in which environment, whether this host can reach that upstream, and whether a model slug that worked last month still exists. The feature itself was rarely the hard part. The surrounding setup was.&lt;/p&gt;

&lt;p&gt;The applications I was building did not need a grand AI platform. They needed to ask a model a question, sometimes stream the answer, and occasionally move away from a provider-specific model name without hunting through several codebases.&lt;/p&gt;

&lt;p&gt;Direct provider integrations make sense, especially for one small application. But with more than one app, credentials need safe homes, upstream model slugs can change, and provider availability can vary by environment or region. I did not want each new application to rediscover those constraints independently.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Foybg9kgz8mugif3t5syr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Foybg9kgz8mugif3t5syr.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Before, each application carries provider setup alongside its own work. After, provider-facing concerns have one small home while applications keep product decisions.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;There are already strong projects in this area. LiteLLM, LangChain, and Vercel AI SDK solve real problems, and I would reach for them when their broader coverage or integrations are what a project needs. I am not trying to replace any of them.&lt;/p&gt;

&lt;p&gt;For my case, a wider framework or gateway meant learning more surface than the job required. I wanted a service I could follow from request to response and deploy next to my apps.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When I would not reach for Go-Ai:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;one app, one provider, and the direct SDK is already fine&lt;/li&gt;
&lt;li&gt;you need an agent runtime, tool executor, memory, or RAG pipeline in the gateway&lt;/li&gt;
&lt;li&gt;you want a managed SaaS, dashboard, or billing layer instead of a small service you run yourself&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  A small gateway, not another application framework
&lt;/h2&gt;

&lt;p&gt;An application backend sends an OpenAI-compatible chat request to Go-Ai, using a local alias rather than a provider model slug. The gateway resolves that alias and proxies the request upstream; the application keeps its user session, validation, rate limits, database access, and response shape.&lt;/p&gt;

&lt;p&gt;That modest boundary gives provider credentials one server-side home and lets an upstream model change without a sweep through every application route. The detailed mechanics -- runtime alias selection, defined fallback classes, the route and SSE contract, audio proxying, and validation -- are in the companion article: &lt;a href="https://jakuninoleg.hashnode.dev/building-a-small-go-llm-gateway-with-explicit-boundaries" rel="noopener noreferrer"&gt;Building a Small Go LLM Gateway with Explicit Boundaries&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Application wiring constructs three provider clients: Gemini and OpenRouter for the chat router, plus Groq for the separate audio service. I like that the wiring is ordinary and visible in &lt;a href="https://github.com/JakuninOleg/Go-Ai/blob/main/cmd/api/main.go" rel="noopener noreferrer"&gt;&lt;code&gt;cmd/api/main.go&lt;/code&gt;&lt;/a&gt;: configuration enters in one place, providers are constructed explicitly, and the chat and audio services have distinct responsibilities.&lt;/p&gt;

&lt;p&gt;Voice stayed on the same principle. Speech-to-text and text-to-speech are separate proxy routes, not a new kind of chat request. The application decides whether an answer should be spoken; the gateway does not turn chat into realtime voice or an automatic speaking feature.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tools belong with the application
&lt;/h2&gt;

&lt;p&gt;Tool calling was where the boundary mattered most to me. Go-Ai can pass OpenAI-compatible tool-related payloads through, but it does not execute tools. The calling application knows who the user is, what they are allowed to do, which tenant or record they can access, and which side effects are acceptable. The gateway does not.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmlhrkajtm9e9t017nqbd.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmlhrkajtm9e9t017nqbd.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The gateway carries provider concerns; the application keeps the context required to make an authorization or business decision. Tool execution stays where that context exists.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;In practice, an application receives a tool call, validates the user and arguments, performs the work against its trusted systems, and sends the follow-up message. It also keeps the complete tool-call message intact between turns, including any opaque provider metadata, instead of trying to rebuild it from a smaller shape. Provider credentials and the bearer secret stay in trusted server-side configuration; a browser talks to its application backend, not directly to the gateway.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it without adopting a new platform
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://github.com/JakuninOleg/Go-Ai" rel="noopener noreferrer"&gt;Go-Ai repository&lt;/a&gt; is the project itself, not a hosted product. You can run it beside an existing backend and decide whether its small boundary is useful.&lt;/p&gt;

&lt;p&gt;Docker Compose is one supported option. Copy the example env, set your own secret and at least the Gemini key, then start it. OpenRouter stays optional unless you want its configured fallback path. Do not put real values in version control.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cp&lt;/span&gt; .env.example .env
&lt;span class="c"&gt;# Edit .env: set GO_AI_SHARED_SECRET and GEMINI_API_KEY.&lt;/span&gt;
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--build&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That starts the repository's Docker setup; it does not validate credentials or promise that an upstream provider is reachable from every host. A simple request from trusted server-side code can look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;GO_AI_BASE_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;http://localhost:8080
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;GO_AI_SHARED_SECRET&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;replace-with-your-gateway-secret

curl &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$GO_AI_BASE_URL&lt;/span&gt;&lt;span class="s2"&gt;/v1/chat/completions"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$GO_AI_SHARED_SECRET&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
    "model": "default",
    "messages": [
      { "role": "user", "content": "Say hello in one sentence." }
    ]
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you want the same boundary inside an existing frontend-backed app, start with the &lt;a href="https://github.com/JakuninOleg/Go-Ai/blob/main/docs/agent-prompts/start-here.md" rel="noopener noreferrer"&gt;agent handoff&lt;/a&gt;: copy the guides into the target repo, keep &lt;code&gt;GO_AI_BASE_URL&lt;/code&gt; and &lt;code&gt;GO_AI_SHARED_SECRET&lt;/code&gt; server-side only, and ask the agent to propose the smallest migration before editing files. The browser must never receive the shared secret.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I deliberately did not build
&lt;/h2&gt;

&lt;p&gt;The omissions are as important as the code that exists:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No agent runtime or tool executor.&lt;/strong&gt; Go-Ai does not plan agent steps, run tools, manage memory, or own a RAG pipeline.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No shared persistent state.&lt;/strong&gt; It is not a durable store for application conversations or cross-instance state.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No broad resilience system.&lt;/strong&gt; It is not a circuit-breaker, retry, or health-orchestration layer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No full OpenAI API, dashboard, or billing layer.&lt;/strong&gt; The gateway exposes the small route set it supports rather than imitating a larger product.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is scope discipline, not a claim that those things are unimportant. If a future application needs full orchestration, a rich client SDK, billing, or broad provider management, I should choose a tool built for that job instead of stretching this one past recognition.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I learned from keeping it small
&lt;/h2&gt;

&lt;p&gt;The most useful result has been removing a repeat decision from the next project: one service boundary and one provider-side home for credentials instead of provider setup in every codebase.&lt;/p&gt;

&lt;p&gt;Building it also gave my Go learning a practical shape: where HTTP errors belong, how to preserve a stream, which state should be local, and where not to hide responsibility.&lt;/p&gt;

&lt;p&gt;My next focus is not to pile features onto Go-Ai. It is to keep the boundary reliable, document integrations clearly, and learn from the cases where the small shape stops being the right one. If you build or try something similar, I would value specific feedback: open an issue in the &lt;a href="https://github.com/JakuninOleg/Go-Ai" rel="noopener noreferrer"&gt;Go-Ai repository&lt;/a&gt;, or contribute a fix, test, or documentation improvement when you see a concrete gap. The most useful contributions will help keep the boundary honest rather than merely making it larger.&lt;/p&gt;

</description>
      <category>go</category>
      <category>ai</category>
      <category>backend</category>
      <category>devjournal</category>
    </item>
  </channel>
</rss>
