> ## Documentation Index
> Fetch the complete documentation index at: https://docs.livepeer.org/llms.txt
> Use this file to discover all available pages before exploring further.

# BYOC CPU Smoke Test

> Verify the Livepeer orchestrator framework works on your machine in under 20 minutes - no GPU, no wallet, no on-chain registration required.

export const StyledStep = ({title, icon, titleSize = 'h3', iconColor = null, titleColor = null, children, className = '', style = {}, ...rest}) => {
  const styledTitle = titleColor ? <span style={{
    color: titleColor
  }}>{title}</span> : title;
  return <Step title={styledTitle} icon={icon} iconColor={iconColor || undefined} titleSize={titleSize} className={className} style={style} {...rest}>
      {children}
    </Step>;
};

export const StyledSteps = ({children, iconColor, titleColor, lineColor, iconSize = '24px', className = '', style = {}, ...rest}) => {
  const resolvedIconColor = iconColor || 'var(--accent-dark, #18794E)';
  const resolvedTitleColor = titleColor || 'var(--lp-color-accent)';
  const resolvedLineColor = lineColor || 'var(--lp-color-accent)';
  return <div className={['docs-styled-steps', className].filter(Boolean).join(' ')} style={style} {...rest}>
      <style>{`
        .docs-styled-steps .steps > div > div.absolute > div {
          background-color: ${resolvedIconColor};
        }
        .docs-styled-steps .steps > div > div.w-full > p {
          color: ${resolvedTitleColor};
        }
        .docs-styled-steps .steps > div > div.absolute.w-px {
          background-color: ${resolvedLineColor};
        }
        .docs-styled-steps .steps > div:last-child > div.absolute.w-px::after {
          content: '';
          position: absolute;
          bottom: 0;
          left: 50%;
          transform: translateX(-50%);
          width: 6px;
          height: 6px;
          background-color: ${resolvedLineColor};
          transform: translateX(-50%) rotate(45deg);
        }
      `}</style>
      <div>
        <Steps>{children}</Steps>
      </div>
    </div>;
};

export const TableCell = ({children, align = "left", header = false, style = {}, className = "", ...rest}) => {
  const Component = header ? "th" : "td";
  return <Component className={className} style={{
    padding: "0.75rem 1rem",
    textAlign: align,
    border: header ? "none" : "1px solid var(--lp-color-border-default)",
    ...style
  }} {...rest}>
      {children}
    </Component>;
};

export const TableRow = ({children, header = false, hover = false, style = {}, className = "", ...rest}) => {
  const rowId = `table-row-${Math.random().toString(36).substr(2, 9)}`;
  return <>
      {hover && <style>{`
          #${rowId}:hover {
            background-color: var(--lp-color-bg-card);
          }
        `}</style>}
      <tr id={rowId} className={className} style={{
    ...header && ({
      backgroundColor: "var(--lp-color-accent-strong)",
      color: "var(--lp-color-on-accent)",
      fontWeight: "bold"
    }),
    ...style
  }} {...rest}>
        {children}
      </tr>
    </>;
};

export const StyledTable = ({children, variant = "default", style = {}, className = "", ...rest}) => {
  const wrapperVariants = {
    default: {
      border: "1px solid var(--lp-color-border-default)",
      backgroundColor: "var(--lp-color-bg-card)",
      overflow: "hidden"
    },
    bordered: {
      border: "2px solid var(--lp-color-accent)",
      backgroundColor: "var(--lp-color-bg-page)",
      overflow: "hidden"
    },
    minimal: {
      border: "none",
      backgroundColor: "transparent",
      overflow: "visible"
    }
  };
  return <div data-docs-styled-table-shell className={className} style={{
    width: "100%",
    padding: 0,
    margin: 0,
    ...wrapperVariants[variant],
    ...style
  }} {...rest}>
      <table data-docs-styled-table style={{
    width: "100%",
    borderCollapse: "collapse",
    borderSpacing: 0,
    margin: 0,
    backgroundColor: "transparent"
  }}>
        {children}
      </table>
    </div>;
};

export const LinkArrow = ({href, label, description, newline = true, borderColor, className = '', style = {}, ...rest}) => {
  const linkArrowStyle = {
    display: 'inline-flex',
    alignItems: 'center',
    justifyContent: 'center',
    gap: "var(--lp-spacing-1)",
    width: 'fit-content',
    ...borderColor && ({
      borderColor
    })
  };
  return <span className={className} style={style} {...rest}>
      {newline && <br />}
      <span style={linkArrowStyle}>
        <a href={href} target="_blank" rel="noopener noreferrer">
          {label}
        </a>
        <Icon icon="arrow-up-right" size={14} color="var(--lp-color-accent)" />
      </span>
      {description && description}
      {description && <div style={{
    height: "var(--lp-spacing-3)"
  }} />}
    </span>;
};

export const CustomDivider = ({color = "var(--lp-color-border-default)", middleText = "", spacing = "default", style = {}, className = "", ...rest}) => {
  const spacingPresets = {
    default: {
      margin: "24px 0"
    },
    overlap: {
      margin: "-1rem 0 -1rem 0"
    },
    tight: {
      margin: "0 0 -1rem 0"
    },
    section: {
      margin: "0 0 -2rem 0"
    },
    sectionOverlap: {
      margin: "-1rem 0 -2rem 0"
    },
    deepOverlap: {
      margin: "-1rem 0 -1.5rem 0"
    }
  };
  const spacingStyle = spacingPresets[spacing] || spacingPresets.default;
  return <div role="separator" aria-orientation="horizontal" className={className} style={{
    display: "flex",
    alignItems: "center",
    ...spacingStyle,
    fontSize: style?.fontSize || "16px",
    height: "fit-content",
    ...style
  }} {...rest}>
      <span style={{
    marginRight: "var(--lp-spacing-px-8)",
    opacity: 0.2
  }}>
        <Icon icon="/snippets/assets/logos/Livepeer-Logo-Symbol-Theme.svg" />
      </span>
      <div style={{
    flex: 1,
    height: "1px",
    background: "var(--lp-color-border-default)",
    opacity: 0.4
  }}></div>
      {middleText && <>
          <Icon icon="circle" size={2} />
          <span style={{
    margin: "0 8px",
    fontWeight: "bold",
    color: color,
    opacity: 0.7
  }}>
            {middleText}
          </span>
          <Icon icon="circle" size={2} />
        </>}
      <div style={{
    flex: 1,
    height: "1px",
    background: "var(--lp-color-border-default)",
    opacity: 0.4
  }}></div>
      <span style={{
    marginLeft: "var(--lp-spacing-px-8)",
    opacity: 0.2
  }}>
        <span style={{
    display: "inline-block",
    transform: "scaleX(-1)"
  }}>
          <Icon icon="/snippets/assets/logos/Livepeer-Logo-Symbol-Theme.svg" />
        </span>
      </span>
    </div>;
};

<Tip>
  This tutorial proves the Livepeer Orchestrator pipeline works on your machine before you commit GPU hardware, ETH, or LPT. A job enters a Gateway, routes to the Orchestrator, executes in a CPU Docker container, and returns a response. No GPU, no wallet, no on-chain registration.
</Tip>

***

This smoke test runs a complete Livepeer Gateway and Orchestrator on one machine using a CPU-only Docker container as a BYOC (Bring Your Own Container) pipeline. By the end, a test job has travelled from the Gateway through the Orchestrator into a custom container and back. Estimated time: 20 minutes.

**You will verify:**

* The Orchestrator accepts and routes BYOC jobs correctly
* Your BYOC container registers and serves requests
* The Gateway receives the result from the Orchestrator

<CustomDivider />

## Prerequisites

<StyledTable variant="bordered">
  <thead>
    <TableRow header>
      <TableCell header>Requirement</TableCell>
      <TableCell header>Verify with</TableCell>
    </TableRow>
  </thead>

  <tbody>
    <TableRow>
      <TableCell>Docker Engine 24+</TableCell>
      <TableCell>`docker --version`</TableCell>
    </TableRow>

    <TableRow>
      <TableCell>Python 3.10+</TableCell>
      <TableCell>`python3 --version`</TableCell>
    </TableRow>

    <TableRow>
      <TableCell>go-livepeer binary (Linux amd64)</TableCell>
      <TableCell>`livepeer --version` (or download below)</TableCell>
    </TableRow>

    <TableRow>
      <TableCell>Ports 7935 and 8935 free</TableCell>
      <TableCell>`ss -tlnp | grep -E ':7935|:8935'` (no output = free)</TableCell>
    </TableRow>
  </tbody>
</StyledTable>

No GPU, Ethereum wallet, Arbitrum RPC, or on-chain registration is required.

<CustomDivider />

## Build the CPU BYOC container

The pipeline is a CPU-only Python passthrough: frames in, frames out unchanged. This proves the job routing framework works without any model inference.

<StyledSteps>
  <StyledStep title="Create the project directory">
    ```bash icon="terminal" filename="setup" theme={"theme":{"light":"github-light","dark":"dark-plus"}}
    mkdir byoc-smoke-test && cd byoc-smoke-test
    ```
  </StyledStep>

  <StyledStep title="Write the pipeline code">
    Create `pipeline.py`:

    ```python icon="code" filename="pipeline.py" theme={"theme":{"light":"github-light","dark":"dark-plus"}}
    import asyncio
    import logging

    from runner.live.pipelines import Pipeline, BaseParams
    from runner.live.trickle import VideoFrame, VideoOutput


    class PassthroughParams(BaseParams):
        """No custom parameters needed for passthrough."""
        pass


    class PassthroughPipeline(Pipeline):
        """
        CPU-only passthrough pipeline for BYOC smoke testing.
        Echoes every incoming frame directly to the output queue.
        No model loading, no GPU required.
        """

        def __init__(self):
            self.frame_queue: asyncio.Queue[VideoOutput] = asyncio.Queue()
            self._running = True

        async def initialize(self, **params):
            logging.info("PassthroughPipeline: initialized (CPU, no model loading)")

        async def put_video_frame(self, frame: VideoFrame, request_id: str):
            if self._running:
                await self.frame_queue.put(VideoOutput(frame, request_id))

        async def get_processed_video_frame(self) -> VideoOutput:
            return await self.frame_queue.get()

        async def update_params(self, **params):
            logging.info(f"PassthroughPipeline: params update: {params}")

        async def stop(self):
            self._running = False
            logging.info("PassthroughPipeline: stopped")

        @classmethod
        def prepare_models(cls):
            logging.info("PassthroughPipeline: no models to prepare (CPU passthrough)")
    ```
  </StyledStep>

  <StyledStep title="Write the entrypoint">
    Create `main.py`:

    ```python icon="code" filename="main.py" theme={"theme":{"light":"github-light","dark":"dark-plus"}}
    import os
    from runner.app import start_app
    from runner.live.pipelines import PipelineSpec

    pipeline_spec = PipelineSpec(
        name="passthrough-cpu",
        pipeline_cls="pipeline:PassthroughPipeline",
        params_cls="pipeline:PassthroughParams",
        initial_params={},
    )

    if __name__ == "__main__":
        start_app(pipeline=pipeline_spec)
    ```
  </StyledStep>

  <StyledStep title="Write the Dockerfile">
    Create `Dockerfile`:

    ```dockerfile icon="code" filename="Dockerfile" theme={"theme":{"light":"github-light","dark":"dark-plus"}}
    ARG BASE_IMAGE=livepeer/ai-runner:live-base
    FROM ${BASE_IMAGE}

    WORKDIR /app

    COPY pipeline.py ./pipeline.py
    COPY main.py ./main.py

    ENV HF_HUB_OFFLINE=1

    ENTRYPOINT ["python", "main.py"]
    ```
  </StyledStep>

  <StyledStep title="Build and verify the image">
    ```bash icon="terminal" filename="build" theme={"theme":{"light":"github-light","dark":"dark-plus"}}
    docker build -t byoc-cpu-passthrough:latest .
    ```

    Expected output ends with:

    ```text icon="terminal" title="Expected image build output" theme={"theme":{"light":"github-light","dark":"dark-plus"}}
    Successfully tagged byoc-cpu-passthrough:latest
    ```

    Verify:

    ```bash icon="terminal" filename="verify-image" theme={"theme":{"light":"github-light","dark":"dark-plus"}}
    docker images | grep byoc-cpu-passthrough
    ```

    One line should appear with a recent creation time.
  </StyledStep>
</StyledSteps>

<CustomDivider />

## Start the Orchestrator

<StyledSteps>
  <StyledStep title="Install go-livepeer">
    Use the existing binary if it is already on the machine.

    ```bash icon="terminal" filename="install" theme={"theme":{"light":"github-light","dark":"dark-plus"}}
    # Replace vX.Y.Z with the latest release tag
    # from github.com/livepeer/go-livepeer/releases
    curl -LO https://github.com/livepeer/go-livepeer/releases/latest/download/livepeer-linux-amd64.tar.gz
    tar -xzf livepeer-linux-amd64.tar.gz
    chmod +x livepeer livepeer_cli
    ```
  </StyledStep>

  <StyledStep title="Start the BYOC container">
    Start the pipeline container before the Orchestrator so it is ready when the Orchestrator registers capabilities:

    ```bash icon="terminal" filename="start-byoc" theme={"theme":{"light":"github-light","dark":"dark-plus"}}
    docker run -d \
      --name byoc-cpu-passthrough \
      --network host \
      -p 8000:8000 \
      byoc-cpu-passthrough:latest
    ```

    Verify the container started cleanly:

    ```bash icon="terminal" filename="check-byoc" theme={"theme":{"light":"github-light","dark":"dark-plus"}}
    docker logs byoc-cpu-passthrough
    ```

    Expected output:

    ```text icon="terminal" title="Expected BYOC container log" theme={"theme":{"light":"github-light","dark":"dark-plus"}}
    PassthroughPipeline: initialized (CPU, no model loading)
    ```
  </StyledStep>

  <StyledStep title="Start the orchestrator (off-chain mode)">
    In a new terminal, run the Orchestrator. Off-chain mode requires no Ethereum wallet or RPC:

    ```bash icon="terminal" filename="start-orchestrator" theme={"theme":{"light":"github-light","dark":"dark-plus"}}
    ./livepeer \
      -orchestrator \
      -serviceAddr 0.0.0.0:8935 \
      -cliAddr 127.0.0.1:7935 \
      -byoc \
      -byocContainerURL http://localhost:8000 \
      -byocModelID passthrough-cpu \
      -pricePerUnit 1 \
      -network offchain \
      -datadir ./data-orchestrator
    ```

    Expected log output:

    ```text icon="terminal" title="Expected orchestrator log" theme={"theme":{"light":"github-light","dark":"dark-plus"}}
    Orchestrator registered with service address 0.0.0.0:8935
    BYOC capability registered: passthrough-cpu
    ```

    Keep this terminal open.
  </StyledStep>
</StyledSteps>

<CustomDivider />

## Start the Gateway

<StyledSteps>
  <StyledStep title="Start the gateway (off-chain mode with remote signer)">
    In a third terminal, start the Gateway. The community remote signer handles all Ethereum operations so no wallet is needed:

    ```bash icon="terminal" filename="start-gateway" theme={"theme":{"light":"github-light","dark":"dark-plus"}}
    ./livepeer \
      -gateway \
      -cliAddr 127.0.0.1:7936 \
      -httpAddr 0.0.0.0:8936 \
      -orchAddr http://localhost:8935 \
      -remoteSignerAddr https://signer.eliteencoder.net \
      -network offchain \
      -datadir ./data-gateway
    ```

    Expected log output:

    ```text icon="terminal" title="Expected gateway startup log" theme={"theme":{"light":"github-light","dark":"dark-plus"}}
    Gateway started on :8936
    Connected to remote signer at https://signer.eliteencoder.net
    Registered orchestrator: localhost:8935
    ```

    <Note>
      The remote signer at `signer.eliteencoder.net` is a community-hosted service for testing off-chain Gateway setups. Confirm availability in [#local-Gateways on Discord](https://discord.gg/livepeer) if you encounter connection errors.
    </Note>
  </StyledStep>

  <StyledStep title="Verify both processes are reachable">
    Check the Gateway API responds:

    ```bash icon="terminal" filename="check-gateway" theme={"theme":{"light":"github-light","dark":"dark-plus"}}
    curl http://localhost:8936/health
    ```

    Expected: `{"status":"ok"}`

    Check the Orchestrator is registered with the Gateway:

    ```bash icon="terminal" filename="check-orch" theme={"theme":{"light":"github-light","dark":"dark-plus"}}
    curl http://localhost:7936/registeredOrchestrators
    ```

    Expected: a JSON array containing your Orchestrator entry at `localhost:8935`.
  </StyledStep>
</StyledSteps>

<CustomDivider />

## Send a test job

<StyledSteps>
  <StyledStep title="Send a BYOC job via curl">
    Send a test payload through the Gateway to the Orchestrator and into the BYOC container:

    ```bash icon="terminal" filename="test-job" theme={"theme":{"light":"github-light","dark":"dark-plus"}}
    curl -X POST http://localhost:8936/live/video-to-video \
      -H "Content-Type: application/octet-stream" \
      --data-binary @/dev/urandom \
      --max-time 10 \
      -o response.bin
    ```

    A non-error HTTP response confirms the pipeline is reachable and the session was established. The response binary is the passthrough output (identical to the input payload).

    Check file size:

    ```bash icon="terminal" filename="check-response" theme={"theme":{"light":"github-light","dark":"dark-plus"}}
    ls -lh response.bin
    ```

    A non-zero file size confirms the full roundtrip completed.
  </StyledStep>

  <StyledStep title="Inspect the log trail">
    Confirm payment signing occurred in the Gateway terminal:

    ```bash icon="terminal" filename="check-logs" theme={"theme":{"light":"github-light","dark":"dark-plus"}}
    # Look for these lines in the gateway terminal output:
    # Calling remote signer: getOrchInfoSig
    # Calling remote signer: signTicket
    ```

    Confirm the Orchestrator received and routed the job:

    ```bash icon="terminal" filename="check-orch-logs" theme={"theme":{"light":"github-light","dark":"dark-plus"}}
    # Look for these in the orchestrator terminal:
    # Received BYOC job: passthrough-cpu
    # Job complete
    ```
  </StyledStep>
</StyledSteps>

<CustomDivider />

## What happened

Three processes ran on your machine and handled a complete job lifecycle:

1. **Your BYOC container** registered as a pipeline called `passthrough-cpu`, exposing a health endpoint at `:8000`.
2. **The Orchestrator** started in off-chain mode, registered the BYOC container's capability, and listened for jobs at `:8935`.
3. **The Gateway** started in off-chain mode with no Ethereum wallet, connected to the community remote signer for payment operations, and pointed at your local Orchestrator.
4. **The test request** entered the Gateway at `:8936`, the Gateway routed it to the Orchestrator (with a payment ticket signed by the remote signer), the Orchestrator forwarded it to the BYOC container, the container processed and returned the payload, and the result returned through the chain to the HTTP response.

The off-chain path used here has no on-chain settlement. In production, an on-chain Orchestrator submits winning payment tickets to the Arbitrum TicketBroker contract for ETH redemption.

<CustomDivider />

## Related pages

<CardGroup cols={2}>
  <Card title="Zero to First Reward" icon="trophy" href="/v2/orchestrators/guides/tutorials/zero-to-first-reward" arrow horizontal>
    Full production setup: install, configure, stake, activate, and claim a first LPT reward with a GPU node.
  </Card>

  <Card title="AI Earning Quickstart" icon="microchip" href="/v2/orchestrators/guides/tutorials/ai-earning-quickstart" arrow horizontal>
    Start earning from AI inference with one GPU and one warm model.
  </Card>

  <Card title="AI Inference Operations" icon="sliders" href="/v2/orchestrators/guides/ai-and-job-workloads/ai-inference-operations" arrow horizontal>
    Full aiModels.json reference and pipeline architecture for production AI workloads.
  </Card>

  <Card title="Dual Mode Configuration" icon="layer-group" href="/v2/orchestrators/guides/deployment-details/dual-mode-configuration" arrow horizontal>
    Run video transcoding and AI inference from a single node.
  </Card>
</CardGroup>
