ACP Agent — Episode 4: How the ACP Job Lifecycle Works (Learned the Hard Way Before Graduation)

After fixing the Evaluator-vs-Provider mistake in Episode 3, jobs finally started coming in. I remember the exact feeling — this rush of "it's actually working" that lasted about four minutes.

Then the first error hit. And then another. And then one at the next phase. And the next.

I'd read about the five phases of an ACP job. REQUEST, NEGOTIATION, TRANSACTION, EVALUATION, COMPLETED. Neat little arrows in the documentation, very clean. What the docs didn't mention is that each phase is basically a new opportunity to break something. And I broke something at every single one.

What This Post Covers

The ACP job lifecycle — all five phases — explained through the errors I hit at each step. If you've read the official docs and still don't understand who signs what and when, this is the messy, real-world version. I'll cover the USDC escrow flow, memo signing rules, and five bugs that taught me more than any documentation ever could.

REQUEST: Where the Job Starts (and Where My Buyer Broke)

A job begins when the buyer calls initiate_job() on the seller's job offering. This locks $0.01 USDC into smart contract escrow. The seller's on_new_task fires, and you decide: accept or reject.

Sounds straightforward. It wasn't.

My buyer code had two problems right out of the gate. First, I'd added an expired_at parameter to initiate_job() because — logically — jobs should have expiration times, right? The SDK disagreed:

# This seemed reasonable: job_id = chosen_offering.initiate_job( service_requirement={"coin": "ETH"}, expired_at=3600, # ← SDK doesn't support this ) # Error: got an unexpected keyword argument 'expired_at'

Turns out the SDK handles expiration internally. I just had to remove the parameter. Took me longer than I'd like to admit to figure out that the fix was deleting a line of code, not adding one.

Second problem: browse_agents() — the function that finds your seller agent — picked the wrong agent entirely. I searched for "PriceVerifier" and it returned some agent called "OracleX" instead. There were multiple agents with similar keywords in their descriptions. I had to add address-based matching to make sure my buyer always found the right seller:

for agent in relevant_agents: addr = getattr(agent, 'wallet_address', '') if "0xcCE25197..." in str(addr): chosen_agent = agent break
What this does in plain English: Instead of searching by name (which can match the wrong agent), it checks the wallet address directly. My agent's address is unique, so this always finds the right one.

Ugly? Sure. But it worked, and at that point I just wanted things to stop breaking for five minutes.

NEGOTIATION: The Phase That Wasn't in My Notes

This one really messed with my head.

The handoff documentation I'd been working from showed: REQUEST → TRANSACTION → EVALUATION → COMPLETED. Four phases.

But in March 2026, there were five. A NEGOTIATION phase had been added between REQUEST and TRANSACTION.

My agent accepted the REQUEST just fine. Then... nothing happened. The job just sat there. No error, no timeout, just silence. I stared at the logs for a genuinely embarrassing amount of time before realizing the job was waiting for me to push it forward.

After accepting a job, the seller needs to create a delivery memo that advances the job to the next phase:

contract_client.handle_operation([ contract_client.create_memo( job_id=job_id, content=json.dumps(verification_result), memo_type=MemoType.MESSAGE, is_secured=False, next_phase=ACPJobPhase.TRANSACTION, ) ])
What this does in plain English: After accepting the job, you tell the system "here's my work, move this job to the payment step." Without this, the job just hangs forever in NEGOTIATION.

Nobody told me this. Or maybe the docs did and I missed it. Either way, I spent an entire session wondering why accepted jobs weren't progressing.

The feeling when you find out the problem is that you needed to add one more step that seems completely obvious in hindsight? That's a special kind of frustration. Like failing a test because you didn't flip the page over.

TRANSACTION: "Only Counter Party Can Sign"

Okay, so now I understood: REQUEST for accepting, NEGOTIATION for delivering, TRANSACTION for payment confirmation.

The TRANSACTION memo arrived, and my seller's polling loop tried to sign it:

# Every time my seller tried to sign the TRANSACTION memo: RPC Error: Only counter party can sign

I panicked a little, not going to lie. Another error, another roadblock. But this one actually made sense once I calmed down and thought about it.

The TRANSACTION memo needs the buyer's signature. Not the seller's. It's the buyer confirming payment. My seller was grabbing every unsigned memo in the polling loop and trying to sign all of them — including ones that weren't its business.

This was the moment I realized something important: each phase has strict rules about who signs. Seller signs at certain points, buyer at others. Trying to sign someone else's memo doesn't crash anything — the SDK just says no — but it means your code doesn't understand the flow.

I added a filter to the polling loop: if the error message says "counter party," just skip that memo. Let the buyer handle it.

EVALUATION to COMPLETED: The Ironic Error

After the buyer signs the TRANSACTION memo, the job moves to EVALUATION. If everything checks out, someone creates a COMPLETED memo to release the escrow.

My seller tried to create it.

# Seller trying to finalize the job: RPC Error: Only evaluators can sign

I actually laughed at this one. After spending 12 sessions trapped as an Evaluator, now I was being told I needed to be one to sign this memo. The universe has a sense of humor.

The fix was moving the COMPLETED memo creation to the buyer side:

# This goes in buyer.py, not main.py: contract_client.handle_operation([ contract_client.create_memo( job_id=job_id, content="Evaluation approved.", memo_type=MemoType.MESSAGE, is_secured=False, next_phase=ACPJobPhase.COMPLETED, ) ])
What this does in plain English: The buyer says "I've reviewed the work and it's good. Release the payment." This is the final step that unlocks the USDC from escrow.

Once this went through — genuinely one of the best moments of the whole project — the escrow released. $0.008 to the seller (that's me), $0.002 to the protocol. The graduation counter finally moved: 1/3, 1/10.

One job. One actual completed job. After everything.

I'm not ashamed to say I took a screenshot.

The USDC Flow (For a $0.01 Job)

Now that I'd seen the full lifecycle end-to-end: $0.01 USDC locks in escrow when the job starts (from the buyer's agent wallet, not MetaMask). On completion, $0.008 goes to the seller, $0.002 to the protocol. If the job gets rejected or expires, the full $0.01 goes back to the buyer.

Quick reminder from Episode 3: these transactions use ERC-4337 Account Abstraction, so they won't show up in normal Basescan tabs. Check Token Transfers specifically.

The Polling Problem

One more thing that drove me crazy. My polling loop kept trying to sign memos it had already signed. "Already signed" errors flooding the logs, 10+ times per minute. Not harmful — the SDK ignores duplicates — but every real event was buried under noise.

Fix: track processed memo IDs in a set. Before signing, check if the ID's already there. Skip if yes. Simple, but it made my logs usable again. Honestly, it made me usable again — debugging while your terminal scrolls faster than you can read is its own kind of torture.

Key Takeaways

  • The ACP lifecycle has five phases now. NEGOTIATION was added between REQUEST and TRANSACTION — check the latest docs, not old guides.
  • Each memo has a specific signer. Seller signs some, buyer signs others. If you get "Only counter party can sign," you're trying to sign the wrong one — just skip it.
  • The COMPLETED memo goes in the buyer's code, not the seller's. Getting this wrong gives you the ironic "Only evaluators can sign" error.
  • Track processed memo IDs to avoid "Already signed" log spam. A simple set() is enough.
  • All USDC movements happen through ERC-4337 — check Token Transfers on Basescan, not regular Transactions.

What's Next

With the lifecycle working, jobs started completing. The graduation counter climbed past 3/3 and 10/10. I submitted for graduation review feeling confident.

That confidence didn't last. Episode 5 is about the automated Graduation Evaluator, four failed attempts with four different rejection reasons, and the realization that passing sandbox tests and passing graduation review are very different things.

← Previous: Episode 3: The Mistake That Blocked Graduation       Next: Episode 5: I Failed the Graduation Evaluation 4 Times →


More updates on the way. If you're working on something similar or found a smarter way to do it, drop it in the comments — the more we share, the faster we all move.

Disclaimer: This blog documents my personal learning journey. Nothing here is financial advice.

Comments