<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" xmlns:googleplay="http://www.google.com/schemas/play-podcasts/1.0"><channel><title><![CDATA[Engineering at Basic Capital]]></title><description><![CDATA[Engineering the retirement system for the modern American worker]]></description><link>https://eng.basiccapital.com</link><image><url>https://substackcdn.com/image/fetch/$s_!dptS!,w_256,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fdab3c39b-efb2-4b37-932a-0171f5af4222_256x256.png</url><title>Engineering at Basic Capital</title><link>https://eng.basiccapital.com</link></image><generator>Substack</generator><lastBuildDate>Fri, 07 Aug 2026 10:42:14 GMT</lastBuildDate><atom:link href="https://eng.basiccapital.com/feed" rel="self" type="application/rss+xml"/><copyright><![CDATA[Hunter Hopcroft]]></copyright><language><![CDATA[en]]></language><webMaster><![CDATA[hunteratbasic@substack.com]]></webMaster><itunes:owner><itunes:email><![CDATA[hunteratbasic@substack.com]]></itunes:email><itunes:name><![CDATA[Basic Capital]]></itunes:name></itunes:owner><itunes:author><![CDATA[Basic Capital]]></itunes:author><googleplay:owner><![CDATA[hunteratbasic@substack.com]]></googleplay:owner><googleplay:email><![CDATA[hunteratbasic@substack.com]]></googleplay:email><googleplay:author><![CDATA[Basic Capital]]></googleplay:author><itunes:block><![CDATA[Yes]]></itunes:block><item><title><![CDATA[Good Backends Are Agent Backends]]></title><description><![CDATA[You&#8217;re already agent-ready]]></description><link>https://eng.basiccapital.com/p/good-backends-are-agent-backends</link><guid isPermaLink="false">https://eng.basiccapital.com/p/good-backends-are-agent-backends</guid><dc:creator><![CDATA[Daniel Bak]]></dc:creator><pubDate>Mon, 13 Jul 2026 20:06:37 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!dptS!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fdab3c39b-efb2-4b37-932a-0171f5af4222_256x256.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h1>Introduction</h1><p>Our <a href="https://eng.basiccapital.com/p/durable-engineering-at-basic-capital">previous post</a> detailed how our backend was structured to support lightweight Temporal workflows as thin coordination layers (following the <a href="https://temporal.io/blog/saga-pattern-made-easy">Saga pattern</a>). Business logic doesn&#8217;t happen inside of workflows, but rather inside RPC endpoints that the workflows interact with.</p><p>AI Agents are workflows where the orchestrator happens to be a language model rather than a static script. A backend designed to support versatile lightweight workflows is already a backend designed to support AI agents. Whether those workflows are deterministic or stochastic in nature shouldn&#8217;t matter from your backend&#8217;s perspective.</p><div class="subscription-widget-wrap-editor" data-attrs="{&quot;url&quot;:&quot;https://eng.basiccapital.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe&quot;,&quot;language&quot;:&quot;en&quot;}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption">Thanks for reading Engineering at Basic Capital! Subscribe for free to receive new posts and support my work.</p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email&#8230;" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div><h1>Agents as Workflow Orchestrators</h1><p>If your backend supports Temporal well, it means you have a system that can answer a large number of business questions with well-scoped endpoints that handle the gathering of data end-to-end, and you support chaining those questions together to handle complex business and analytics needs. Doesn&#8217;t that sound exactly like an agent workflow?</p><p>What does make an AI different than Temporal is <em>trust</em>. Your workflows were written by a human - or at least code reviewed by one. AI are not (yet) accountable or predictable like a human is, so they can&#8217;t have the same access scope that your workflows have. Agents represent a novel security threat; a highly capable and potentially hazardous actor that you deploy within your own infrastructure. <a href="https://cloud.google.com/blog/topics/threat-intelligence/data-theft-salesforce-instances-via-salesloft-drift">Your</a> <a href="https://thehackernews.com/2025/06/zero-click-ai-vulnerability-exposes.html">agent</a> <a href="https://www.anthropic.com/news/disrupting-AI-espionage">will</a> <a href="https://www.anthropic.com/news/detecting-and-countering-malicious-uses-of-claude-march-2025">leak</a> <a href="https://thehackernews.com/2025/11/chinese-hackers-use-anthropics-ai-to.html">everything</a> <a href="https://www.theregister.com/2025/07/21/replit_saastr_vibe_coding_incident/">and</a> <a href="https://alexeyondata.substack.com/p/how-i-dropped-our-production-database">break</a> <a href="https://www.tomshardware.com/tech-industry/artificial-intelligence/claude-powered-ai-coding-agent-deletes-entire-company-database-in-9-seconds-backups-zapped-after-cursor-tool-powered-by-anthropics-claude-goes-rogue">things</a>. Build first with the concept that AI agents are maximally hostile, and add trust from there.</p><p>Agents are operating on your developer&#8217;s machine, potentially with a developer&#8217;s internet access and permissions. You don&#8217;t want it firing off an email containing customer&#8217;s PII, and you don&#8217;t want it deleting production customer records because it thinks they&#8217;re causing a bug. Your backend access layer needs to make this <strong>structurally impossible</strong>. Prompt engineering is not enough.</p><p>This is why giving your agent unrestricted access to a tool like <a href="https://github.com/fullstorydev/grpcurl">https://github.com/fullstorydev/grpcurl</a> is a mistake. Our solution was to deploy an MCP proxy, <a href="https://github.com/Basic-Capital/grpcmcp">https://github.com/Basic-Capital/grpcmcp</a>, which we stood up with kubernetes. Many MCPs can be hosted locally on a developer&#8217;s machine, but our MCP server is acting both as a standard tool use helper and as access control, so it needs to be hosted in an environment that an agent can&#8217;t modify. This proxy reflects a subset of our endpoints that we&#8217;ve demarcated as ai-accessible using just a single protobuf field and one protobuf annotation:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;kotlin&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-kotlin">enum AiAccessLevel {
  AI_ACCESS_LEVEL_BLOCKED = 0;
  AI_ACCESS_LEVEL_ALLOWED = 1;        // both internal and customer-facing
  AI_ACCESS_LEVEL_INTERNAL_ONLY = 2;  // internal tools only
}

extend google.protobuf.MethodOptions {
  optional AiAccessLevel ai_access_level = 50003;
}</code></pre></div><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;kotlin&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-kotlin">rpc GetDataAboutSomething(...) returns (...) {
  option (bcoptions.ai_access_level) = AI_ACCESS_LEVEL_ALLOWED;
}</code></pre></div><p>This is the access control surface for AI in our codebase. Any method is <strong>BLOCKED</strong> by default. This makes it very easy to adjust and interpret access control in-code.</p><p><strong>grpcmcp</strong> picks up the annotation through <strong>--require-method-option</strong>, a flag that filters which gRPC methods are exposed as MCP tools by proto method-option tag and value.</p><p>Enforcement lives in a single gRPC interceptor, which looks to the X509 certificate used against our MTLS encrypted RPC endpoints to determine the actor type and then enforces the access level as follows:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;kotlin&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-kotlin">class AiAccessLevelInterceptor : ServerInterceptor {
    override fun &lt;ReqT, RespT&gt; interceptCall(...): Listener&lt;ReqT&gt; {
        val level = call.methodDescriptor.schemaDescriptor
            ?.let { it as? ProtoMethodDescriptorSupplier }
            ?.methodDescriptor?.options
            ?.getExtension(CustomOptions.aiAccessLevel)
            ?: AiAccessLevel.AI_ACCESS_LEVEL_BLOCKED

        val isExternalAgent = requestContext?.actorType ==
            ActorType.EXTERNAL_AI_AGENT

        val allowed = when (level) {
            AI_ACCESS_LEVEL_ALLOWED       -&gt; true
            AI_ACCESS_LEVEL_INTERNAL_ONLY -&gt; !isExternalAgent
            else                          -&gt; false
        }

        if (!allowed) {
            call.close(Status.PERMISSION_DENIED, Metadata())
            return NoOpListener()
        }

        return next.startCall(piiRedactingCall, headers)
    }
}</code></pre></div><p>Every PII field in our protos carries a <a href="https://github.com/protocolbuffers/protobuf/blob/main/src/google/protobuf/descriptor.proto"><span>debug_redact</span></a> annotation &#8212; a built-in protobuf field option that we dual-purposed as the source of truth for "this field should never leave the wire to an agent". These annotations allow our RPC interceptor to trivially enforce redacting by reading the annotation and stripping the field before the response leaves the interceptor.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;kotlin&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-kotlin">message Human {
  string uuid = 1;
  string given_name = 2 [debug_redact = true];
  optional string middle_name = 14 [debug_redact = true];
  string surname = 3 [debug_redact = true];
  Date date_of_birth = 4 [debug_redact = true];
  string email = 5 [debug_redact = true];
  optional string phone_number = 6 [debug_redact = true];
}</code></pre></div><p>This gave us <strong>jarvis</strong>, the in-house name for our internal AI assistant. Rather than laboriously chaining together RPC calls by hand to solve production issues, Claude Code (or any other agentic harness) can talk to the <strong>jarvis</strong> MCP and one-shot diagnose and fix a lot of production problems using natural language.</p><p>Agents surface issues when they talk to your backend, and descriptive errors will let the agent know why a call failed or what an alternative endpoint to use would be. Well-named methods are easier for the agent to retrieve from context. All of these principles are table stakes in traditional software engineering, but in our new world they also happen to reduce query time and hallucinations. Agents shouldn&#8217;t be an excuse to fire off the slop cannons but an additive reward for good design.</p><p>The next step was rolling out a customer-facing version of this tool. A key insight is that there&#8217;s not a large distinction between a power user and a debugging developer; both want to chain together business logic to answer novel questions.</p><p>This gave us the idea for <strong>Vision</strong>, the customer-facing version of <strong>jarvis</strong>. Rather than living in a standalone proxy, <strong>Vision</strong> lives in our application code, and effectively defines an alternative backend interface to the user. Rather than the traditional approach of building out extensive, bespoke, feature-creeped visualizations for power users, an agent allows our clever customers to answer novel questions that we didn&#8217;t predict.</p><h1>Agents as Frontends</h1><p>In the same way that <strong>jarvis</strong> is a stochastic analogue to Temporal, <strong>Vision</strong> is a stochastic analogue to a user dashboard. It reuses the user&#8217;s auth to provide multi-tenancy, structurally preventing data leaks between users.</p><p>Vision runs as an in-process gRPC server inside the application backend, so the model&#8217;s tool calls flow through the same interceptor chain as production traffic from our user-facing dashboards; auth, PII redaction, request-context propagation, tracing, etc:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;kotlin&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-kotlin">private val server: Server =
    InProcessServerBuilder.forName(serverName)
        .intercept(AiAccessLevelInterceptor())
        .intercept(AssistantRequestContextInterceptor(auditDao, env, jwtManager))
        .addAuthInterceptors(systemSecurity)
        .intercept(GrpcServerExceptionInterceptor(env, includeDebugInfo = true))
        .apply { services.forEach { addService(it) } }
        .build()</code></pre></div><p>Every tool call the model issues is wrapped in call credentials that carry the customer&#8217;s real auth token, with the request context tagged as agent-driven.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;kotlin&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-kotlin">internal class BearerTokenCredentials(
    private val token: String,
    private val identity: Identity,
    private val userUuid: String,
    private val parentContext: RequestContext? = null,
) : CallCredentials() {
    override fun applyRequestMetadata(...) {
        val requestContext = RequestContext(
            actorType  = ActorType.EXTERNAL_AI_AGENT,
            actor      = "Vision",
            actingFor  = userUuid,
            parent     = parentContext,
        )
        val metadata = Metadata()
        metadata.put(GrpcMetadataKeys.Authorization, "Bearer $token")
        metadata.put(GrpcMetadataKeys.IdentityType,  identity.type.name)
        metadata.put(GrpcMetadataKeys.IdentityUuid,  identity.uuid)
        applier.apply(metadata)
    }
}</code></pre></div><p>Vision was powerful out-of-the-box because it was dogfooding flows that our engineering and operations teams had already been using. Developers are heavily incentivized to fix their own pain, so extending these developer tools to users gives us naturally battle-tested products that are agent-ready.</p><div class="subscription-widget-wrap-editor" data-attrs="{&quot;url&quot;:&quot;https://eng.basiccapital.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe&quot;,&quot;language&quot;:&quot;en&quot;}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption">Thanks for reading Engineering at Basic Capital! Subscribe for free to receive new posts and support my work.</p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email&#8230;" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div>]]></content:encoded></item><item><title><![CDATA[Durable Engineering at Basic Capital]]></title><description><![CDATA[Using Temporal with Kotlin to make building joyful]]></description><link>https://eng.basiccapital.com/p/durable-engineering-at-basic-capital</link><guid isPermaLink="false">https://eng.basiccapital.com/p/durable-engineering-at-basic-capital</guid><dc:creator><![CDATA[Anjan Karanam]]></dc:creator><pubDate>Wed, 11 Feb 2026 22:07:14 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!dptS!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fdab3c39b-efb2-4b37-932a-0171f5af4222_256x256.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2>Introduction</h2><p>Most server applications start simple: a request comes in, the server does some work on a thread, the server sends back a response. Eventually, one inevitably runs into a class of engineering problem that doesn&#8217;t fit this model: a process that spans minutes, hours, or days. A payment that needs to wait for external settlement. An onboarding flow that blocks on identity verification. A multi-step order that can fail at any point and needs to pick up exactly where it left off.</p><p>The instinct is to reach for queues, cron jobs, and state machines stitched together with database flags. We&#8217;ve all built these systems. They work, right up until they devolve into piecing together state from databases and queues as you joylessly debug why something happened twice, or why a customer got stuck in a half-completed state that your recovery logic didn&#8217;t account for. A whole class of engineer was created in the 2010&#8217;s to reinvent the solution to this problem at every company.</p><p>At Basic Capital, &#8220;quality of life&#8221; for our technical staff is an important metric for success. Building at Basic Capital should inspire joy, it should feel like playing <a href="https://factorio.com/">Factorio</a>, and not be a death-by-thousand-chores slog.</p><p><a href="https://temporal.io/">Temporal</a> offers a different model: write your long-running process as a straightforward function, and let the infrastructure handle durability. Your code reads like a simple procedural program, but it survives process restarts, network failures, and deployments without losing progress.</p><p>Our inaugural blog-post walks through how we set up Temporal in a Kotlin backend, the architectural insight that made it click for us, how it has yielded compounding returns for our engineering and ops teams, and how it&#8217;s laid the foundation for agentic AI products.</p><h2>The Mental Model</h2><p>The key insight powering Basic Capital&#8217;s foundational systems: a Temporal workflow doesn&#8217;t need to contain business logic. It can simply coordinate it.</p><p>In our architecture, the application backend is a set of gRPC services that owns all business logic, database access, and domain rules. Temporal workflows sit alongside them as orchestrators, calling into those services, waiting for external events, and ensuring that multi-step processes complete reliably.</p><p>This means:</p><ul><li><p>Application services remain the single source of truth for business logic</p></li><li><p>Workflows are lightweight coordination layers, easy to read and reason about</p></li><li><p>You get durability without polluting data models with orchestration artifacts</p></li></ul><h2>Setting it up with an example</h2><p>Suppose you wanted to build a simple order fulfillment workflow at Basic Capital: an order is created, waits for payment confirmation from an external provider, and then gets fulfilled. If the payment succeeds, we ship it, otherwise we cancel the order.</p><h3>Step 1: Define the Workflow Interface</h3><p>Workflow interfaces live in a shared library so both the application server (which starts workflows) and the worker (which executes them) can reference them.</p><pre><code><code>@WorkflowInterface
interface OrderFulfillmentWorkflow : RunnableWorkflow {
    @WorkflowMethod
    fun run(orderUuid: String)

    @SignalMethod
    fun paymentCompleted(success: Boolean)
}</code></code></pre><p>A few things to note:</p><ul><li><p><code>@WorkflowInterface</code> and <code>@WorkflowMethod</code> are Temporal annotations that define the entry point</p></li><li><p><code>@SignalMethod</code> defines a method that external systems can call to push data into a running workflow</p></li></ul><p>Signals are what make Temporal workflows genuinely reactive. Instead of polling a database for state changes, your application backend pushes events directly into the running workflow.</p><h3>Step 2: Define the Activities</h3><p>Activities are where side effects happen. In our architecture, they&#8217;re thin wrappers around gRPC calls:</p><pre><code><code>@ActivityInterface
interface OrderFulfillmentSteps : BasicCapitalActivity {
    fun getOrder(orderUuid: String): Order
    fun reserveInventory(orderUuid: String)
    fun fulfillOrder(orderUuid: String)
    fun cancelOrder(orderUuid: String, reason: String)
}

class OrderFulfillmentStepsImpl(
    private val orderClient: OrderServiceCoroutineStub,
) : OrderFulfillmentSteps {
    override fun getOrder(orderUuid: String): Order {
        return runBlocking {
            orderClient.getOrder(
                GetOrderRequest(orderUuid = orderUuid)
&#9;).order
        }
    }
&#9;
    override fun reserveInventory(orderUuid: String) {
        runBlocking {
            orderClient.reserveInventory(
                ReserveInventoryRequest(orderUuid = orderUuid)
            )
        }
    }
&#9;
    override fun fulfillOrder(orderUuid: String) {
        runBlocking {
            orderClient.fulfillOrder(
                FulfillOrderRequest(orderUuid = orderUuid)
            )
        }
    }&#9;
&#9;
    override fun cancelOrder(orderUuid: String, reason: String) {
        runBlocking {
            orderClient.cancelOrder(
                CancelOrderRequest(orderUuid = orderUuid, reason = reason)
            )
        }
    }
}</code></code></pre><p>Notice what&#8217;s not here: no database queries, no business rules, no validation. All of that lives in the gRPC service implementation. The activity is just a remote procedure call.</p><h3>Step 3: Implement the Workflow</h3><p>Now the satisfying part, the workflow implementation reads like pseudocode:</p><pre><code><code>class OrderFulfillmentWorkflowImpl : OrderFulfillmentWorkflow {
    private val steps = getActivityStub(OrderFulfillmentSteps::class)
    private var paymentSucceeded: Boolean? = null

    override fun run(orderUuid: String) {
        // Reserve inventory immediately
        steps.reserveInventory(orderUuid)
&#9;&#9;
        // Wait for payment provider callback &#8212; could be seconds or hours
        Workflow.await { paymentSucceeded != null }
&#9;&#9;
        if (paymentSucceeded == true) {
            steps.fulfillOrder(orderUuid)
        } else {
            steps.cancelOrder(orderUuid, "Payment failed")
        }
    }
&#9;
    override fun paymentCompleted(success: Boolean) {
        this.paymentSucceeded = success
    }
}
</code></code></pre><p><code>Workflow.await { paymentSucceeded != null }</code> is the magic line. The workflow suspends here, not by burning a thread, but by persisting its state and shutting down. When a signal arrives via <code>paymentCompleted(...)</code>, Temporal replays the workflow history, restores the state, evaluates the condition, and resumes execution.</p><p>If the worker crashes while waiting, nothing is lost. If you deploy a new version, it picks up where it left off. The workflow could wait for five seconds, five days, or even five weeks &#8212; the outcome is the same.</p><h3>Step 4: Configure Task Queues</h3><p>Activities and workflows are grouped into task queues, which map to pools of workers. We created our own abstraction on top of this to make it especially straightforward for engineers (or Claude Code).</p><p>You can configure multiple task queues, each with their own configured parallelism and rate limits.</p><h3>Step 5: Wire it all up</h3><p>On the application side, starting a workflow is a one-liner wrapped in a thin client we created for convenience:</p><pre><code><code>class BasicCapitalTemporal(private val workflowClient: WorkflowClient) {

    // Idempotent workflow creation
    fun &lt;T : RunnableWorkflow&gt; startNewWorkflow(
        workflowId: String,
        workflowProcessor: KClass&lt;T&gt;,
        workflowInvocation: (T) -&gt; Unit,
    ): WorkflowMetadata {
        val workflow = workflowClient.newWorkflowStub(
            workflowProcessor.java,
            WorkflowOptions.newBuilder()
                .setTaskQueue(workflowProcessor.getTaskQueue())
                .setWorkflowId(workflowId)
                .setWorkflowIdConflictPolicy(USE_EXISTING)
                .build(),
            )
&#9;
        val execution = WorkflowClient.start { workflowInvocation(workflow) }
            return WorkflowMetadata(execution.workflowId, execution.runId)
        }

    // Signaling existing workflows
    fun &lt;T : RunnableWorkflow&gt; withExistingWorkflow(
        workflowId: String,
        workflowProcessor: KClass&lt;T&gt;,
        fn: (T) -&gt; Unit,
    ) {
        val workflow =
            temporalClient.newWorkflowStub(
            workflowProcessor.java,
            workflowId,
        )
            
        fn(workflow)
    }
}</code></code></pre><p>The <code>USE_EXISTING</code> conflict policy makes workflow creation naturally idempotent. Additionally, as a convention, we use our business objects&#8217; unique identifiers as their workflowId&#8217;s.</p><p>From a gRPC endpoint, kicking off the workflow looks like:</p><pre><code><code>// Inside your gRPC service handler
val order = orderDao.createOrder(request)

temporal.startNewWorkflow(
    workflowId = order.uuid,
    workflowProcessor = OrderFulfillmentWorkflow::class,
) {
    it.run(order.uuid)
}

// Inside the event handler for an external payment completion webhook event
temporal.withExistingWorkflow(
    workflowId = orderUuid,
    workflowProcessor = OrderFulfillmentWorkflow::class,
) {
    it.paymentCompleted(success = true)
}</code></code></pre><p>That&#8217;s it. The webhook handler sends a signal, the workflow wakes up and continues.</p><h2>Configuring Retry and Timeout Behavior</h2><p>The <a href="https://docs.temporal.io/encyclopedia/temporal-sdks">Temporal SDK</a> allows you to configure retry policies and timeouts:</p><pre><code><code>private val retryOptions = RetryOptions.newBuilder()
    .setInitialInterval(Duration.ofSeconds(5))
    .setMaximumInterval(Duration.ofDays(1))
    .setBackoffCoefficient(2.0)
    .setMaximumAttempts(18)
    .build()
&#9;
private val defaultActivityOptions = ActivityOptions.newBuilder()
    .setRetryOptions(retryOptions)
    .setStartToCloseTimeout(Duration.ofMinutes(5))
    .build()</code></code></pre><p>The appropriate configuration is situationally dependent, but the arrangement that works for us today is an exponential backoff from 5 seconds to 1 day and 18 max attempts. A transient failure gets retried aggressively at first and gracefully backs off, covering roughly 5 days of retry window.</p><h2>Why This Architecture Works</h2><p>The pattern of &#8220;Temporal as orchestrator, gRPC services as executors&#8221; has a few properties that compound over time:</p><ul><li><p>Workflows stay simple - Because they only coordinate calls and manage state transitions, most of our workflows are under 50 lines. They&#8217;re easy to review, easy to test, and easy to reason about when something goes wrong.</p></li><li><p>Business logic doesn&#8217;t scatter - Every domain operation lives in exactly one place: the gRPC service that owns it. Temporal never needs to know how inventory reservation works&#8212;it just knows to call something like <code>reserveInventory()</code>, and handle success or failure.</p></li><li><p>The system is observable - Temporal&#8217;s UI shows every running workflow, its current state, its full event history, and every signal it&#8217;s received. When a workflow is stuck, you can see exactly which activity failed and how many times it&#8217;s been retried.</p></li><li><p>Signals replace polling - Instead of workflows polling a database every 30 seconds to check if payment completed, our webhook handlers can simply send a signal directly to the workflow.</p></li></ul><h2>The Bigger Picture</h2><p>The value of Temporal isn&#8217;t that it does something you can&#8217;t do with queues and state machines. It&#8217;s that it lets you express complex, long-running processes in code that looks boring: sequential, readable, and obviously correct. In production systems that handle real money, boring is exactly what you want.</p><p>We run 70+ workflow types in production, covering everything from account onboarding to trade settlement to SPV incorporation. Each one follows the same pattern: a workflow interface with signals, an activity interface that wraps gRPC calls, and an implementation that reads like a step-by-step runbook.</p><p>Think of it like automating a human sitting and stringing together API calls to run your business. This mental model is particularly powerful, as it has laid the foundation for us to build Agentic operations, allowing us to use natural language to orchestrate complex research and processes on top of our foundational platform. We&#8217;re excited to share more about this very soon.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!4C6I!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb753b377-561a-4de1-a35f-5bb299144e95_5440x2016.heic" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!4C6I!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb753b377-561a-4de1-a35f-5bb299144e95_5440x2016.heic 424w, https://substackcdn.com/image/fetch/$s_!4C6I!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb753b377-561a-4de1-a35f-5bb299144e95_5440x2016.heic 848w, https://substackcdn.com/image/fetch/$s_!4C6I!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb753b377-561a-4de1-a35f-5bb299144e95_5440x2016.heic 1272w, https://substackcdn.com/image/fetch/$s_!4C6I!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb753b377-561a-4de1-a35f-5bb299144e95_5440x2016.heic 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!4C6I!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb753b377-561a-4de1-a35f-5bb299144e95_5440x2016.heic" width="1456" height="540" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/b753b377-561a-4de1-a35f-5bb299144e95_5440x2016.heic&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:540,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:138608,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/heic&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://eng.basiccapital.com/i/187637188?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb753b377-561a-4de1-a35f-5bb299144e95_5440x2016.heic&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!4C6I!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb753b377-561a-4de1-a35f-5bb299144e95_5440x2016.heic 424w, https://substackcdn.com/image/fetch/$s_!4C6I!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb753b377-561a-4de1-a35f-5bb299144e95_5440x2016.heic 848w, https://substackcdn.com/image/fetch/$s_!4C6I!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb753b377-561a-4de1-a35f-5bb299144e95_5440x2016.heic 1272w, https://substackcdn.com/image/fetch/$s_!4C6I!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb753b377-561a-4de1-a35f-5bb299144e95_5440x2016.heic 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>At Basic Capital, we&#8217;re building a vertically integrated 401(k) and IRA product, a native record keeping system, brokerage &amp; trading systems, credit origination systems, the world&#8217;s only <a href="https://basiccapital.com/how-it-works">Retirement Mortgage</a>, and agentic plan administration. Our engineering challenges span financial systems, UI/UX, 3rd party integrations and partner API&#8217;s, data engineering, and AI infrastructure. Our bedrock makes building all of these things feel downright joyful, and we&#8217;re just getting started. If you&#8217;re interested in joining, <a href="https://basiccapital.com/careers">we&#8217;re hiring</a>.</p>]]></content:encoded></item></channel></rss>